diff --git a/.changeset/purple-pots-doubt.md b/.changeset/purple-pots-doubt.md new file mode 100644 index 00000000000..d5d1fe9f2af --- /dev/null +++ b/.changeset/purple-pots-doubt.md @@ -0,0 +1,5 @@ +--- +'@sap-ux/generator-adp': patch +--- + +FIX: Write headless generation result for BAS orchestrator when JSON input provides a correlation id diff --git a/packages/generator-adp/src/app/index.ts b/packages/generator-adp/src/app/index.ts index 6a29097e62e..3438afccbe2 100644 --- a/packages/generator-adp/src/app/index.ts +++ b/packages/generator-adp/src/app/index.ts @@ -60,6 +60,7 @@ import { } from '../utils/steps.js'; import { addDeployGen, addExtProjectGen, addFlpGen } from '../utils/subgenHelpers.js'; import { getTemplatesOverwritePath } from '../utils/templates.js'; +import { writeResult } from '../utils/write-result.js'; import { existsInWorkspace, handleWorkspaceFolderChoice, showWorkspaceFolderWarning } from '../utils/workspace.js'; import { getFlexLayer } from './layer.js'; import { getPrompts } from './questions/attributes.js'; @@ -167,6 +168,10 @@ export default class extends Generator { * Indicates if the current environment is a CF environment. */ private isCfEnv = false; + /** + * Tracks whether the writing phase failed, to prevent end() from overwriting a failure result. + */ + private writingFailed = false; /** * Indicates if the user is logged in to CF. */ @@ -429,7 +434,12 @@ export default class extends Generator { await generate(this._getProjectPath(), config, this.fs); } catch (e) { - this.logger.error(`Writing phase failed: ${e}`); + const message = e instanceof Error ? e.message : String(e); + this.logger.error(`Writing phase failed: ${message}`); + this.writingFailed = true; + if (this.jsonInput?.id) { + writeResult(this.jsonInput.id, `Failure: ${message}`); + } throw new Error(t('error.updatingApp')); } finally { cacheClear(this.appWizard, this.logger); @@ -450,6 +460,12 @@ export default class extends Generator { async end(): Promise { const projectPath = this._getProjectPath(); + + // Report the generated project path back to the BAS orchestrator once files are written to disk. + if (this.jsonInput?.id && !this.writingFailed) { + writeResult(this.jsonInput.id, projectPath); + } + const data = TelemetryHelper.createTelemetryData({ appType: 'generator-adp', ...this.options.telemetryData, diff --git a/packages/generator-adp/src/app/types.ts b/packages/generator-adp/src/app/types.ts index c7bfc677387..f09f31e6a8c 100644 --- a/packages/generator-adp/src/app/types.ts +++ b/packages/generator-adp/src/app/types.ts @@ -293,6 +293,12 @@ export interface ExtensionProjectData { * generator configurations. The json is passed as an CLI argument. */ export interface JsonInput { + /** + * Correlation key sent by the SAP Business Application Studio orchestrator. When present, the + * generation outcome (project path on success, `Failure: ` on error) is written back + * under this key so the orchestrator can poll the result of an async headless generation. + */ + id?: string; system: string; client?: string; username?: string; diff --git a/packages/generator-adp/src/utils/type-guards.ts b/packages/generator-adp/src/utils/type-guards.ts index 0c6875535a7..deffe935f4c 100644 --- a/packages/generator-adp/src/utils/type-guards.ts +++ b/packages/generator-adp/src/utils/type-guards.ts @@ -22,6 +22,7 @@ export function isJsonInput(value: unknown): value is JsonInput { } return ( + isOptionalString(value.id) && isString(value.system) && isString(value.application) && isOptionalString(value.applicationTitle) && diff --git a/packages/generator-adp/src/utils/write-result.ts b/packages/generator-adp/src/utils/write-result.ts new file mode 100644 index 00000000000..b9015cd3d9d --- /dev/null +++ b/packages/generator-adp/src/utils/write-result.ts @@ -0,0 +1,40 @@ +import { existsSync, readFileSync, writeFileSync } from 'node:fs'; + +/** + * Absolute path of the file the SAP Business Application Studio orchestrator polls for + * headless generation results. Each result is stored under the correlation `id` supplied + * in the generator JSON input. + */ +export const RESULT_FILE_PATH = '/home/user/tmpProjectTemplate.json'; + +/** + * Writes the outcome of a headless generation run to the orchestrator result file, keyed by + * the correlation `id` from the JSON input. Existing entries are preserved; a missing or + * malformed result file is treated as empty. The result file is a best-effort side channel: + * a write failure is swallowed so it never masks the actual generation outcome. + * + * @param {string} id - The correlation key supplied by the orchestrator in the JSON input. + * @param {string} result - The generated project path on success, or `Failure: ` on error. + */ +export function writeResult(id: string, result: string): void { + let fileContent: Record = {}; + + if (existsSync(RESULT_FILE_PATH)) { + try { + const parsed: unknown = JSON.parse(readFileSync(RESULT_FILE_PATH, 'utf-8')); + if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { + // Safe after the runtime object check; the file only ever holds a flat id -> result map. + fileContent = parsed as Record; + } + } catch { + fileContent = {}; + } + } + + fileContent[id] = result; + try { + writeFileSync(RESULT_FILE_PATH, JSON.stringify(fileContent)); + } catch { + // Best-effort side channel; never let a failed result write break generation. + } +} diff --git a/packages/generator-adp/test/app.test.ts b/packages/generator-adp/test/app.test.ts index 8a17cfb643c..ef0e0c55543 100644 --- a/packages/generator-adp/test/app.test.ts +++ b/packages/generator-adp/test/app.test.ts @@ -26,6 +26,7 @@ import type { Manifest, ManifestNamespace } from '@sap-ux/project-access'; const mockIsInternalFeaturesSettingEnabled = jest.fn(); const mockGetDefaultProjectName = jest.fn(); +const mockWriteResult = jest.fn<(id: string, result: string) => void>(); const mockValidateExtensibilityGenerator = jest.fn().mockReturnValue(true); const mockResolveNodeModuleGenerator = jest.fn().mockReturnValue('my-generator-path'); const mockShowApplicationQuestion = jest.fn().mockReturnValue(true); @@ -189,6 +190,12 @@ jest.unstable_mockModule('../src/utils/templates', () => ({ getTemplatesOverwritePath: mockGetTemplatesOverwritePath })); +const realWriteResult = await import('../src/utils/write-result.js'); +jest.unstable_mockModule('../src/utils/write-result', () => ({ + ...realWriteResult, + writeResult: mockWriteResult +})); + const realFioriGeneratorShared = await import('@sap-ux/fiori-generator-shared'); jest.unstable_mockModule('@sap-ux/fiori-generator-shared', () => ({ ...realFioriGeneratorShared, @@ -742,12 +749,60 @@ describe('Adaptation Project Generator Integration Test', () => { expect(changeContent).toMatchSnapshot(); }); + it('should not call writeResult when json input has no id', async () => { + mockGetSupportedProject.mockRejectedValueOnce(new Error('no-id-error')); + const jsonInput: JsonInput = { + system: 'urlA', + username: 'user1', + password: 'pass1', + client: '010', + application: 'sap.ui.demoapps.f1', + projectName: 'noid.app', + namespace: 'customer.noid.app', + targetFolder: testOutputDir, + projectType: AdaptationProjectType.ON_PREMISE + }; + + const runContext = yeomanTest + .create(adpGenerator, { resolved: generatorPath }, { cwd: testOutputDir }) + .withArguments([JSON.stringify(jsonInput)]); + + await expect(runContext.run()).rejects.toThrow(t('error.updatingApp')); + + expect(mockWriteResult).not.toHaveBeenCalled(); + }); + + it('should write a failure result for the orchestrator when json generation fails', async () => { + mockGetSupportedProject.mockRejectedValueOnce(new Error('boom')); + const jsonInput: JsonInput = { + id: 'orchestrator-id', + system: 'urlA', + username: 'user1', + password: 'pass1', + client: '010', + application: 'sap.ui.demoapps.f1', + projectName: 'fail.app', + namespace: 'customer.fail.app', + targetFolder: testOutputDir, + projectType: AdaptationProjectType.ON_PREMISE + }; + + const runContext = yeomanTest + .create(adpGenerator, { resolved: generatorPath }, { cwd: testOutputDir }) + .withArguments([JSON.stringify(jsonInput)]); + + await expect(runContext.run()).rejects.toThrow(t('error.updatingApp')); + + expect(mockWriteResult).toHaveBeenCalledWith('orchestrator-id', 'Failure: boom'); + }); + it('should create adaptation project from json correctly', async () => { // NOTE: This test uses .withArguments() which bypasses the normal yeoman prompting lifecycle and goes directly to the writing phase. // This can cause race conditions with other tests that use the same output directory, as the generator doesn't go through the standard prompting -> writing flow. // This test must be the last test in the file. Other tests below it must use a different output directory. mockGetSupportedProject.mockResolvedValue(SupportedProject.ON_PREM); const jsonInput: JsonInput = { + id: 'orchestrator-id', system: 'urlA', username: 'user1', password: 'pass1', @@ -785,6 +840,8 @@ describe('Adaptation Project Generator Integration Test', () => { expect(manifestContent).toMatchSnapshot(); expect(i18nContent).toMatchSnapshot(); expect(ui5Content).toMatchSnapshot(); + + expect(mockWriteResult).toHaveBeenCalledWith('orchestrator-id', projectFolder); }); }); diff --git a/packages/generator-adp/test/unit/utils/type-guards.test.ts b/packages/generator-adp/test/unit/utils/type-guards.test.ts index 6948f9c7902..1c8fc34343f 100644 --- a/packages/generator-adp/test/unit/utils/type-guards.test.ts +++ b/packages/generator-adp/test/unit/utils/type-guards.test.ts @@ -40,6 +40,14 @@ describe('isJsonInput', () => { ).toBe(true); }); + it('should return true when the optional id is a string', () => { + expect(isJsonInput({ id: 'correlation-id', system: 'system', application: 'application' })).toBe(true); + }); + + it('should return false when id is present but not a string', () => { + expect(isJsonInput({ id: 123, system: 'system', application: 'application' })).toBe(false); + }); + it('should return false if some of the required fields are missing', () => { expect( isJsonInput({ diff --git a/packages/generator-adp/test/unit/utils/write-result.test.ts b/packages/generator-adp/test/unit/utils/write-result.test.ts new file mode 100644 index 00000000000..631b3b1acd1 --- /dev/null +++ b/packages/generator-adp/test/unit/utils/write-result.test.ts @@ -0,0 +1,78 @@ +import { jest } from '@jest/globals'; + +const mockExistsSync = jest.fn(); +const mockReadFileSync = jest.fn(); +const mockWriteFileSync = jest.fn(); + +const realFs = await import('node:fs'); +jest.unstable_mockModule('node:fs', () => ({ + ...realFs, + existsSync: mockExistsSync, + readFileSync: mockReadFileSync, + writeFileSync: mockWriteFileSync +})); + +const { writeResult, RESULT_FILE_PATH } = await import('../../../src/utils/write-result.js'); + +describe('writeResult', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should write the project path under the given id when the file does not exist', () => { + mockExistsSync.mockReturnValue(false); + + writeResult('id-1', '/home/user/projects/app.variant'); + + expect(mockReadFileSync).not.toHaveBeenCalled(); + expect(mockWriteFileSync).toHaveBeenCalledWith( + RESULT_FILE_PATH, + JSON.stringify({ 'id-1': '/home/user/projects/app.variant' }) + ); + }); + + it('should write a failure result under the given id', () => { + mockExistsSync.mockReturnValue(false); + + writeResult('id-1', 'Failure: something went wrong'); + + expect(mockWriteFileSync).toHaveBeenCalledWith( + RESULT_FILE_PATH, + JSON.stringify({ 'id-1': 'Failure: something went wrong' }) + ); + }); + + it('should merge the new result into existing entries', () => { + mockExistsSync.mockReturnValue(true); + mockReadFileSync.mockReturnValue(JSON.stringify({ existing: '/path/existing' })); + + writeResult('id-2', '/path/new'); + + expect(mockWriteFileSync).toHaveBeenCalledWith( + RESULT_FILE_PATH, + JSON.stringify({ existing: '/path/existing', 'id-2': '/path/new' }) + ); + }); + + it.each([ + ['malformed JSON', 'not-json'], + ['a non-object value', '42'], + ['an array', '[1,2]'] + ])('should treat %s result file as empty', (_label, fileContent) => { + mockExistsSync.mockReturnValue(true); + mockReadFileSync.mockReturnValue(fileContent); + + writeResult('id-3', '/path/new'); + + expect(mockWriteFileSync).toHaveBeenCalledWith(RESULT_FILE_PATH, JSON.stringify({ 'id-3': '/path/new' })); + }); + + it('should swallow a write failure instead of throwing', () => { + mockExistsSync.mockReturnValue(false); + mockWriteFileSync.mockImplementation(() => { + throw new Error('EACCES'); + }); + + expect(() => writeResult('id-6', '/path/new')).not.toThrow(); + }); +});