Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/purple-pots-doubt.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@sap-ux/generator-adp': patch
---

FIX: Write headless generation result for BAS orchestrator when JSON input provides a correlation id
18 changes: 17 additions & 1 deletion packages/generator-adp/src/app/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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.
*/
Expand Down Expand Up @@ -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);
Expand All @@ -450,6 +460,12 @@ export default class extends Generator {

async end(): Promise<void> {
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);
Comment thread
testojs marked this conversation as resolved.
}

const data = TelemetryHelper.createTelemetryData({
appType: 'generator-adp',
...this.options.telemetryData,
Expand Down
6 changes: 6 additions & 0 deletions packages/generator-adp/src/app/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: <message>` 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;
Expand Down
1 change: 1 addition & 0 deletions packages/generator-adp/src/utils/type-guards.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) &&
Expand Down
40 changes: 40 additions & 0 deletions packages/generator-adp/src/utils/write-result.ts
Original file line number Diff line number Diff line change
@@ -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';
Comment thread
testojs marked this conversation as resolved.

/**
* 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: <message>` on error.
*/
export function writeResult(id: string, result: string): void {
let fileContent: Record<string, string> = {};

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<string, string>;
}
} 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.
}
}
57 changes: 57 additions & 0 deletions packages/generator-adp/test/app.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import type { Manifest, ManifestNamespace } from '@sap-ux/project-access';

const mockIsInternalFeaturesSettingEnabled = jest.fn<typeof realFeatureToggle.isInternalFeaturesSettingEnabled>();
const mockGetDefaultProjectName = jest.fn<typeof realDefaultValues.getDefaultProjectName>();
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);
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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);
});
});

Expand Down
8 changes: 8 additions & 0 deletions packages/generator-adp/test/unit/utils/type-guards.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
78 changes: 78 additions & 0 deletions packages/generator-adp/test/unit/utils/write-result.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { jest } from '@jest/globals';

const mockExistsSync = jest.fn<typeof realFs.existsSync>();
const mockReadFileSync = jest.fn<typeof realFs.readFileSync>();
const mockWriteFileSync = jest.fn<typeof realFs.writeFileSync>();

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();
});
});
Loading