diff --git a/packages/adk/src/base/registry.ts b/packages/adk/src/base/registry.ts index 917cc6169..82e83bfaa 100644 --- a/packages/adk/src/base/registry.ts +++ b/packages/adk/src/base/registry.ts @@ -187,7 +187,8 @@ export function resolveKind(kind: AdkKind): RegistryEntry | undefined { * Get ADK kind for ADT type */ export function getKindForType(adtType: string): AdkKind | undefined { - return adtToKind.get(getMainType(adtType)); + const normalizedType = adtType.toUpperCase(); + return adtToKind.get(normalizedType) ?? adtToKind.get(getMainType(adtType)); } /** @@ -201,7 +202,7 @@ export function getTypeForKind(kind: AdkKind): string | undefined { * Check if ADT type is registered */ export function isTypeRegistered(adtType: string): boolean { - return registry.has(getMainType(adtType)); + return resolveType(adtType) !== undefined; } /** diff --git a/packages/adk/src/objects/cts/transport-import.ts b/packages/adk/src/objects/cts/transport-import.ts index e40ee852f..1f47e11c4 100644 --- a/packages/adk/src/objects/cts/transport-import.ts +++ b/packages/adk/src/objects/cts/transport-import.ts @@ -14,6 +14,7 @@ import type { AdkContext } from '../../base/context'; import { getGlobalContext } from '../../base/global-context'; +import { getRegisteredTypes } from '../../base/registry'; import type { TransportGetResponse } from '../../base/adt'; import type { TransportData } from './transport/transport.types'; @@ -188,10 +189,19 @@ export class AdkTransportObjectRef { const { createAdk } = await import('../../factory'); const adk = createAdk(this.ctx.client); + const ctsType = this.type.trim().toUpperCase(); + const workbenchType = this.wbtype?.trim().toUpperCase(); + const factoryType = + workbenchType && + workbenchType.startsWith(`${ctsType}/`) && + getRegisteredTypes().includes(workbenchType) + ? workbenchType + : ctsType; + // Use the factory to get the object // The factory handles type resolution and loading try { - const obj = adk.get(this.name, this.type); + const obj = adk.get(this.name, factoryType); // Check if object has load method (AdkGenericObject doesn't) if ('load' in obj && typeof obj.load === 'function') { await obj.load(); diff --git a/packages/adk/src/objects/repository/incl/incl.model.ts b/packages/adk/src/objects/repository/incl/incl.model.ts index 28b94c547..afe49f19b 100644 --- a/packages/adk/src/objects/repository/incl/incl.model.ts +++ b/packages/adk/src/objects/repository/incl/incl.model.ts @@ -175,3 +175,6 @@ import { registerObjectType } from '../../../base/registry'; registerObjectType('INCL', IncludeKind, AdkInclude, { endpoint: 'programs/includes', }); +registerObjectType('PROG/I', IncludeKind, AdkInclude, { + endpoint: 'programs/includes', +}); diff --git a/packages/adk/tests/incl.test.ts b/packages/adk/tests/incl.test.ts index 01d6252b6..3a759699a 100644 --- a/packages/adk/tests/incl.test.ts +++ b/packages/adk/tests/incl.test.ts @@ -11,6 +11,9 @@ import { describe, it, expect, vi } from 'vitest'; import { AdkInclude } from '../src/objects/repository/incl/incl.model'; import type { AdkContext } from '../src/base/context'; +import { Include } from '../src/base/kinds'; +import { getTypeForKind } from '../src/base/registry'; +import { createAdkFactory } from '../src/factory'; // ── helpers ────────────────────────────────────────────────────────────────── @@ -90,6 +93,21 @@ function createCtx( // ── AdkInclude ────────────────────────────────────────────────────────────── describe('AdkInclude', () => { + it('resolves PROG/I and legacy INCL to the include model and source endpoint', async () => { + const { ctx, includes } = createCtx(); + const factory = createAdkFactory(ctx); + const typed = factory.get('ZAPI_INCLUDE', 'PROG/I'); + const legacy = factory.get('ZAPI_LEGACY_INCLUDE', 'INCL'); + + expect(typed).toBeInstanceOf(AdkInclude); + expect(legacy).toBeInstanceOf(AdkInclude); + expect(getTypeForKind(Include)).toBe('PROG/I'); + + await (typed as AdkInclude).getSource(); + + expect(includes.source.main.get).toHaveBeenCalledWith('ZAPI_INCLUDE'); + }); + it('objectUri constructs lowercase URI for the include', () => { const { ctx } = createCtx(); const obj = new AdkInclude(ctx, 'ZTEST_INCLUDE'); diff --git a/packages/adk/tests/registry.test.ts b/packages/adk/tests/registry.test.ts index f357c19b1..74f7b77b8 100644 --- a/packages/adk/tests/registry.test.ts +++ b/packages/adk/tests/registry.test.ts @@ -161,6 +161,14 @@ describe('getKindForType', () => { expect(getKindForType('TABL/DS')).toBe(kinds.Table); }); + it('should prefer an exact registered subtype and fall back to its base type', () => { + registerObjectType('PROG', kinds.Program, mockCtor); + registerObjectType('PROG/I', kinds.Include, mockCtor); + + expect(getKindForType('PROG/I')).toBe(kinds.Include); + expect(getKindForType('PROG/UNKNOWN')).toBe(kinds.Program); + }); + it('should return undefined for unregistered type', () => { expect(getKindForType('UNREG')).toBeUndefined(); }); @@ -191,6 +199,13 @@ describe('isTypeRegistered', () => { registerObjectType('TABL', kinds.Table, mockCtor); expect(isTypeRegistered('TABL/DS')).toBe(true); }); + + it('should recognize an exact subtype without treating unknown siblings as registered', () => { + registerObjectType('TABL/DS', kinds.Structure as AdkKind, mockCtor); + + expect(isTypeRegistered('TABL/DS')).toBe(true); + expect(isTypeRegistered('TABL/UNKNOWN')).toBe(false); + }); }); describe('getRegisteredTypes', () => { diff --git a/packages/adk/tests/transport-import.test.ts b/packages/adk/tests/transport-import.test.ts index c568d01d9..8fbbc06fc 100644 --- a/packages/adk/tests/transport-import.test.ts +++ b/packages/adk/tests/transport-import.test.ts @@ -12,6 +12,12 @@ import { describe, it, expect, beforeEach, vi } from 'vitest'; +const factoryGet = vi.fn(); + +vi.mock('../src/factory', () => ({ + createAdk: vi.fn(() => ({ get: factoryGet })), +})); + // Mock the ADT client // Response must have 'root' wrapper to match TransportmanagementSingleSchema const mockTransportResponse = { @@ -694,6 +700,11 @@ describe('MergedTransportView', () => { }); describe('AdkTransportObjectRef', () => { + beforeEach(() => { + factoryGet.mockReset(); + factoryGet.mockReturnValue({ load: vi.fn(async () => undefined) }); + }); + it('should have correct type and name', async () => { const mockClient = createMockClient(); @@ -723,8 +734,40 @@ describe('AdkTransportObjectRef', () => { expect(objRef.isDeleted).toBe(false); }); - // Note: objRef.load() test would require mocking the ADK factory - // which is more complex - skipping for now as it requires full ADK setup + it('uses a registered workbench subtype to select the factory object', async () => { + const { AdkTransportObjectRef } = await import('../src/index'); + const objRef = new AdkTransportObjectRef({} as any, { + pgmid: 'R3TR', + type: 'TABL', + wbtype: 'TABL/DS', + name: 'ZAPI_STRUCTURE', + }); + + await objRef.load(); + + expect(factoryGet).toHaveBeenCalledWith('ZAPI_STRUCTURE', 'TABL/DS'); + }); + + it('falls back to the normalized CTS type when the workbench subtype is absent or unregistered', async () => { + const { AdkTransportObjectRef } = await import('../src/index'); + const absentSubtype = new AdkTransportObjectRef({} as any, { + pgmid: 'R3TR', + type: ' tabl ', + name: 'ZAPI_TABLE', + }); + const unregisteredSubtype = new AdkTransportObjectRef({} as any, { + pgmid: 'R3TR', + type: ' tabl ', + wbtype: ' tabl/unknown ', + name: 'ZAPI_UNKNOWN', + }); + + await absentSubtype.load(); + await unregisteredSubtype.load(); + + expect(factoryGet).toHaveBeenNthCalledWith(1, 'ZAPI_TABLE', 'TABL'); + expect(factoryGet).toHaveBeenNthCalledWith(2, 'ZAPI_UNKNOWN', 'TABL'); + }); }); describe('AdkTransportTaskRef', () => { diff --git a/packages/adt-plugin-abapgit/src/lib/handlers/adk.ts b/packages/adt-plugin-abapgit/src/lib/handlers/adk.ts index b9e7ff3f4..73a575060 100644 --- a/packages/adt-plugin-abapgit/src/lib/handlers/adk.ts +++ b/packages/adt-plugin-abapgit/src/lib/handlers/adk.ts @@ -10,6 +10,7 @@ export { AdkInterface, AdkPackage, AdkProgram, + AdkInclude, AdkFunctionGroup, AdkFunctionModule, AdkDomain, diff --git a/packages/adt-plugin-abapgit/src/lib/handlers/objects/prog.ts b/packages/adt-plugin-abapgit/src/lib/handlers/objects/prog.ts index bb4074d64..0da2d510a 100644 --- a/packages/adt-plugin-abapgit/src/lib/handlers/objects/prog.ts +++ b/packages/adt-plugin-abapgit/src/lib/handlers/objects/prog.ts @@ -2,11 +2,27 @@ * Program (PROG) object handler for abapGit format */ -import { AdkProgram } from '../adk'; +import { AdkInclude, AdkProgram } from '../adk'; import { prog } from '../../../schemas/generated'; import { createHandler } from '../base'; -export const programHandler = createHandler(AdkProgram, { +function programSubc(obj: AdkProgram | AdkInclude): string { + if (obj.kind === AdkInclude.kind) return 'I'; + if (!obj.isLoaded) return '1'; + switch (obj.dataSync.programType) { + case 'modulePool': + return 'M'; + case 'subroutinePool': + return 'S'; + default: + return '1'; + } +} + +export const programHandler = createHandler< + AdkProgram | AdkInclude, + typeof prog +>('PROG', { schema: prog, version: 'v1.0.0', serializer: 'LCL_OBJECT_PROG', @@ -17,7 +33,7 @@ export const programHandler = createHandler(AdkProgram, { PROGDIR: { NAME: obj.name ?? '', STATE: 'A', - SUBC: '1', + SUBC: programSubc(obj), FIXPT: 'X', UNICODE: 'X', }, @@ -38,13 +54,22 @@ export const programHandler = createHandler(AdkProgram, { // SUBC can be parsed as number or string depending on XML parser const subc = String(PROGDIR?.SUBC ?? ''); - return { + const data = { name: (PROGDIR?.NAME ?? '').toUpperCase(), - type: 'PROG/P', description: descriptionEntry?.ENTRY ?? 'Program', fixPointArithmetic: PROGDIR?.FIXPT === 'X', activeUnicodeCheck: PROGDIR?.UNICODE === 'X', - programType: subc === '1' ? 'executableProgram' : 'subroutinePool', + }; + if (subc === 'I') return { ...data, type: 'PROG/I' }; + return { + ...data, + type: 'PROG/P', + programType: + subc === 'M' + ? 'modulePool' + : subc === 'S' + ? 'subroutinePool' + : 'executableProgram', }; }, diff --git a/packages/adt-plugin-abapgit/tests/serializer.test.ts b/packages/adt-plugin-abapgit/tests/serializer.test.ts index 72b5ee307..ec9e61036 100644 --- a/packages/adt-plugin-abapgit/tests/serializer.test.ts +++ b/packages/adt-plugin-abapgit/tests/serializer.test.ts @@ -4,6 +4,7 @@ import { existsSync, mkdtempSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { AbapGitSerializer } from '../src/lib/serializer.ts'; +import { programHandler } from '../src/lib/handlers/objects/prog.ts'; import '../src/lib/handlers/objects/index.ts'; const tempDirs: string[] = []; @@ -14,6 +15,31 @@ function createTempDir(): string { return dir; } +function programValuesFromXml( + xml: string, +): Parameters>[0] { + const parsed = programHandler.schema.parse(xml); + assert.ok(parsed && typeof parsed === 'object'); + const abapGit = (parsed as { abapGit?: unknown }).abapGit; + assert.ok(abapGit && typeof abapGit === 'object'); + const abap = (abapGit as { abap?: unknown }).abap; + assert.ok(abap && typeof abap === 'object'); + const values = (abap as { values?: unknown }).values; + assert.ok(values && typeof values === 'object'); + return values as Parameters< + NonNullable + >[0]; +} + +function generatedContent( + files: { path: string; content: string }[], + suffix: string, +): string { + const file = files.find((candidate) => candidate.path.endsWith(suffix)); + assert.ok(file); + return file.content; +} + afterEach(() => { for (const dir of tempDirs.splice(0, tempDirs.length)) { rmSync(dir, { recursive: true, force: true }); @@ -21,6 +47,107 @@ afterEach(() => { }); describe('AbapGitSerializer', () => { + it('serializes a PROG/I include as prog files with SUBCI', async () => { + const includeSource = 'INCLUDE zapi_include.'; + const files = await programHandler.serialize({ + name: 'ZAPI_INCLUDE', + kind: 'Include', + getSource: async () => includeSource, + } as never); + + assert.deepEqual(files.map((file) => file.path).sort(), [ + 'zapi_include.prog.abap', + 'zapi_include.prog.xml', + ]); + assert.match( + files.find((file) => file.path.endsWith('.xml'))!.content, + /I<\/SUBC>/, + ); + }); + + it('keeps executable reports at SUBC1', async () => { + const files = await programHandler.serialize({ + name: 'ZAPI_REPORT', + kind: 'Program', + getSource: async () => 'REPORT zapi_report.', + } as never); + + assert.match( + files.find((file) => file.path.endsWith('.xml'))!.content, + /1<\/SUBC>/, + ); + }); + + it('maps generated Include and report metadata and hands sources to deserialization', async () => { + const includeSource = 'INCLUDE ztest_include.'; + const reportSource = 'REPORT ztest_report.'; + const includeFiles = await programHandler.serialize({ + name: 'ZTEST_INCLUDE', + kind: 'Include', + getSource: async () => includeSource, + } as never); + const reportFiles = await programHandler.serialize({ + name: 'ZTEST_REPORT', + kind: 'Program', + getSource: async () => reportSource, + } as never); + + const fromAbapGit = programHandler.fromAbapGit; + assert.ok(fromAbapGit); + const includeXml = generatedContent(includeFiles, '.xml'); + const reportXml = generatedContent(reportFiles, '.xml'); + const generatedIncludeSource = generatedContent(includeFiles, '.abap'); + const generatedReportSource = generatedContent(reportFiles, '.abap'); + assert.equal(generatedIncludeSource, includeSource); + assert.equal(generatedReportSource, reportSource); + const include = fromAbapGit(programValuesFromXml(includeXml)); + const report = fromAbapGit(programValuesFromXml(reportXml)); + + assert.equal(include.type, 'PROG/I'); + assert.ok(!('programType' in include)); + assert.equal(report.type, 'PROG/P'); + assert.ok('programType' in report); + assert.equal(report.programType, 'executableProgram'); + const setSources = programHandler.setSources; + assert.ok(setSources); + const includeTarget: { _pendingSource?: string } = {}; + const reportTarget: { _pendingSource?: string } = {}; + setSources(includeTarget as never, { main: generatedIncludeSource }); + setSources(reportTarget as never, { main: generatedReportSource }); + assert.equal(includeTarget._pendingSource, generatedIncludeSource); + assert.equal(reportTarget._pendingSource, generatedReportSource); + }); + + it('preserves module and subroutine program types in generated metadata', async () => { + const moduleFiles = await programHandler.serialize({ + name: 'ZTEST_MODULE', + kind: 'Program', + isLoaded: true, + dataSync: { programType: 'modulePool' }, + getSource: async () => 'PROGRAM ztest_module.', + } as never); + const subroutineFiles = await programHandler.serialize({ + name: 'ZTEST_SUBROUTINE', + kind: 'Program', + isLoaded: true, + dataSync: { programType: 'subroutinePool' }, + getSource: async () => 'PROGRAM ztest_subroutine.', + } as never); + + const fromAbapGit = programHandler.fromAbapGit; + assert.ok(fromAbapGit); + const moduleXml = generatedContent(moduleFiles, '.xml'); + const subroutineXml = generatedContent(subroutineFiles, '.xml'); + assert.match(moduleXml, /M<\/SUBC>/); + assert.match(subroutineXml, /S<\/SUBC>/); + const module = fromAbapGit(programValuesFromXml(moduleXml)); + const subroutine = fromAbapGit(programValuesFromXml(subroutineXml)); + assert.ok('programType' in module); + assert.equal(module.programType, 'modulePool'); + assert.ok('programType' in subroutine); + assert.equal(subroutine.programType, 'subroutinePool'); + }); + it('writes namespaced BDEF files below the target directory', async () => { const targetPath = createTempDir(); const serializer = new AbapGitSerializer(); diff --git a/packages/adt-services/src/services/import/service.ts b/packages/adt-services/src/services/import/service.ts index 87bce3b6b..09f1a8beb 100644 --- a/packages/adt-services/src/services/import/service.ts +++ b/packages/adt-services/src/services/import/service.ts @@ -753,9 +753,10 @@ export class ImportService { ); } - // Step 4: Load ADK object via factory + // Step 4: Load the full ADT type via the factory so registered subtypes + // select their specific endpoint; the format registry uses the base type. const factory = createAdkFactory(ctx); - const adkObject = factory.get(String(exactMatch.name), baseType); + const adkObject = factory.get(String(exactMatch.name), fullType); await (adkObject as any).load(); // Step 5: Serialize via format plugin diff --git a/packages/adt-services/tests/services/import/object.test.ts b/packages/adt-services/tests/services/import/object.test.ts index ac9b2e01e..b102dc194 100644 --- a/packages/adt-services/tests/services/import/object.test.ts +++ b/packages/adt-services/tests/services/import/object.test.ts @@ -2,6 +2,8 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; const factoryGet = vi.fn(); const formatImport = vi.fn(); +const quickSearch = vi.fn(); +const isSupported = vi.fn(); vi.mock('@abapify/adk', () => ({ AdkPackage: { @@ -17,20 +19,7 @@ vi.mock('@abapify/adk', () => ({ repository: { informationsystem: { search: { - quickSearch: vi.fn(async () => ({ - objectReference: [ - { - name: 'Z_SHARED_NAME', - type: 'BDEF/BDO', - packageName: 'ZPKG', - }, - { - name: 'Z_SHARED_NAME', - type: 'DDLX', - packageName: 'ZPKG', - }, - ], - })), + quickSearch, }, }, }, @@ -44,7 +33,7 @@ vi.mock('../../../src/utils/format-loader', () => ({ name: 'abapGit', description: 'abapGit format plugin', instance: { - registry: { isSupported: vi.fn(() => true) }, + registry: { isSupported }, format: { import: formatImport }, hooks: {}, }, @@ -60,11 +49,28 @@ describe('ImportService.importObject()', () => { beforeEach(() => { factoryGet.mockReset(); formatImport.mockReset(); + quickSearch.mockReset(); + isSupported.mockReset(); factoryGet.mockImplementation((_name, type) => ({ type, load: vi.fn(async () => undefined), })); formatImport.mockResolvedValue({ success: true, filesCreated: [] }); + isSupported.mockReturnValue(true); + quickSearch.mockResolvedValue({ + objectReference: [ + { + name: 'Z_SHARED_NAME', + type: 'BDEF/BDO', + packageName: 'ZPKG', + }, + { + name: 'Z_SHARED_NAME', + type: 'DDLX', + packageName: 'ZPKG', + }, + ], + }); }); it('selects the requested type when the same name belongs to multiple object types', async () => { @@ -97,4 +103,31 @@ describe('ImportService.importObject()', () => { ); expect(factoryGet).not.toHaveBeenCalled(); }); + + it('uses the full search type for the factory while checking format support by CTS base type', async () => { + isSupported.mockImplementation((type: string) => type === 'TABL'); + quickSearch.mockResolvedValue({ + objectReference: [ + { + name: 'ZAPI_STRUCTURE', + type: 'TABL/DS', + packageName: 'ZPKG', + }, + ], + }); + const { ImportService } = + await import('../../../src/services/import/service'); + + const result = await new ImportService().importObject({ + objectName: 'ZAPI_STRUCTURE', + objectType: 'TABL', + outputPath: '/tmp/import-object', + format: 'abapgit', + }); + + expect(factoryGet).toHaveBeenCalledWith('ZAPI_STRUCTURE', 'TABL/DS'); + expect(isSupported).toHaveBeenCalledWith('TABL'); + expect(isSupported).not.toHaveBeenCalledWith('TABL/DS'); + expect(result.objectType).toBe('TABL'); + }); });