diff --git a/docs/api/classes/FragmentSwapExtensionService.md b/docs/api/classes/FragmentSwapExtensionService.md new file mode 100644 index 0000000..cc8a3e8 --- /dev/null +++ b/docs/api/classes/FragmentSwapExtensionService.md @@ -0,0 +1,155 @@ +[**@adobe/genstudio-extensibility-sdk**](../README.md) + +*** + +[@adobe/genstudio-extensibility-sdk](../globals.md) / FragmentSwapExtensionService + +# Class: FragmentSwapExtensionService + +Manages swap field extension functionality for swapping field content + +## Constructors + +### new FragmentSwapExtensionService() + +> **new FragmentSwapExtensionService**(): [`FragmentSwapExtensionService`](FragmentSwapExtensionService.md) + +#### Returns + +[`FragmentSwapExtensionService`](FragmentSwapExtensionService.md) + +## Methods + +### close() + +> `static` **close**(`connection`: `any`): `void` + +Closes the swap field extension dialog + +#### Parameters + +##### connection + +`any` + +The guest connection to the host + +#### Returns + +`void` + +#### Throws + +Error if connection is missing + +*** + +### getExperience() + +> `static` **getExperience**(`connection`: `any`): `Promise`\<[`Experience`](../interfaces/Experience.md)\> + +Gets the current field context from the host + +#### Parameters + +##### connection + +`any` + +The guest connection to the host + +#### Returns + +`Promise`\<[`Experience`](../interfaces/Experience.md)\> + +Promise The current field context + +#### Throws + +Error if connection is missing + +*** + +### getGenerationContext() + +> `static` **getGenerationContext**(`connection`: `any`): `Promise`\<[`GenerationContext`](../type-aliases/GenerationContext.md)\> + +Gets the generation context from the host + +#### Parameters + +##### connection + +`any` + +The guest connection to the host + +#### Returns + +`Promise`\<[`GenerationContext`](../type-aliases/GenerationContext.md)\> + +Promise The generation context + +#### Throws + +Error if connection is missing + +*** + +### open() + +> `static` **open**(`connection`: `any`, `extensionId`: `string`): `void` + +Opens the swap field extension dialog + +#### Parameters + +##### connection + +`any` + +The guest connection to the host + +##### extensionId + +`string` + +The ID of the extension to open + +#### Returns + +`void` + +#### Throws + +Error if connection is missing + +*** + +### setSwapValue() + +> `static` **setSwapValue**(`connection`: `any`, `value`: `string`): `void` + +Sets the swap value for the field content + +#### Parameters + +##### connection + +`any` + +The guest connection to the host + +##### value + +`string` + +The new value to write into the field + +#### Returns + +`void` + +#### Throws + +Error if connection is missing diff --git a/docs/api/classes/FragmentSwapExtensionServiceError.md b/docs/api/classes/FragmentSwapExtensionServiceError.md new file mode 100644 index 0000000..e626f2d --- /dev/null +++ b/docs/api/classes/FragmentSwapExtensionServiceError.md @@ -0,0 +1,127 @@ +[**@adobe/genstudio-extensibility-sdk**](../README.md) + +*** + +[@adobe/genstudio-extensibility-sdk](../globals.md) / FragmentSwapExtensionServiceError + +# Class: FragmentSwapExtensionServiceError + +## Extends + +- `Error` + +## Constructors + +### new FragmentSwapExtensionServiceError() + +> **new FragmentSwapExtensionServiceError**(`message`: `string`): [`FragmentSwapExtensionServiceError`](FragmentSwapExtensionServiceError.md) + +#### Parameters + +##### message + +`string` + +#### Returns + +[`FragmentSwapExtensionServiceError`](FragmentSwapExtensionServiceError.md) + +#### Overrides + +`Error.constructor` + +## Properties + +### message + +> **message**: `string` + +#### Inherited from + +`Error.message` + +*** + +### name + +> **name**: `string` + +#### Inherited from + +`Error.name` + +*** + +### stack? + +> `optional` **stack**: `string` + +#### Inherited from + +`Error.stack` + +*** + +### prepareStackTrace()? + +> `static` `optional` **prepareStackTrace**: (`err`: `Error`, `stackTraces`: `CallSite`[]) => `any` + +Optional override for formatting stack traces + +#### Parameters + +##### err + +`Error` + +##### stackTraces + +`CallSite`[] + +#### Returns + +`any` + +#### See + +https://v8.dev/docs/stack-trace-api#customizing-stack-traces + +#### Inherited from + +`Error.prepareStackTrace` + +*** + +### stackTraceLimit + +> `static` **stackTraceLimit**: `number` + +#### Inherited from + +`Error.stackTraceLimit` + +## Methods + +### captureStackTrace() + +> `static` **captureStackTrace**(`targetObject`: `object`, `constructorOpt`?: `Function`): `void` + +Create .stack property on a target object + +#### Parameters + +##### targetObject + +`object` + +##### constructorOpt? + +`Function` + +#### Returns + +`void` + +#### Inherited from + +`Error.captureStackTrace` diff --git a/docs/api/globals.md b/docs/api/globals.md index 3acedc8..e3a2253 100644 --- a/docs/api/globals.md +++ b/docs/api/globals.md @@ -11,6 +11,8 @@ ## Classes - [ExtensionAuthError](classes/ExtensionAuthError.md) +- [FragmentSwapExtensionService](classes/FragmentSwapExtensionService.md) +- [FragmentSwapExtensionServiceError](classes/FragmentSwapExtensionServiceError.md) - [ImportTemplateExtensionService](classes/ImportTemplateExtensionService.md) - [ImportTemplateExtensionServiceError](classes/ImportTemplateExtensionServiceError.md) - [PromptExtensionService](classes/PromptExtensionService.md) @@ -24,6 +26,7 @@ - [Experience](interfaces/Experience.md) - [ExperienceField](interfaces/ExperienceField.md) +- [FragmentSwapExtensionApi](interfaces/FragmentSwapExtensionApi.md) - [ImportTemplateExtensionApi](interfaces/ImportTemplateExtensionApi.md) - [PromptExtensionApi](interfaces/PromptExtensionApi.md) - [SelectContentExtensionApi](interfaces/SelectContentExtensionApi.md) diff --git a/docs/api/interfaces/FragmentSwapExtensionApi.md b/docs/api/interfaces/FragmentSwapExtensionApi.md new file mode 100644 index 0000000..9b0d9b7 --- /dev/null +++ b/docs/api/interfaces/FragmentSwapExtensionApi.md @@ -0,0 +1,77 @@ +[**@adobe/genstudio-extensibility-sdk**](../README.md) + +*** + +[@adobe/genstudio-extensibility-sdk](../globals.md) / FragmentSwapExtensionApi + +# Interface: FragmentSwapExtensionApi + +## Extends + +- `VirtualApi` + +## Indexable + +\[`key`: `string`\]: `object` \| (...`args`: `unknown`[]) => `unknown` + +## Properties + +### api + +> **api**: \{ `fragmentSwapExtension`: \{ `close`: () => `void`; `getExperience`: () => `Promise`\<[`Experience`](Experience.md)\>; `getGenerationContext`: () => `Promise`\<[`GenerationContext`](../type-aliases/GenerationContext.md)\>; `open`: (`extensionId`: `string`) => `void`; `setSwapValue`: (`value`: `string`) => `void`; \}; \} + +#### fragmentSwapExtension + +> **fragmentSwapExtension**: \{ `close`: () => `void`; `getExperience`: () => `Promise`\<[`Experience`](Experience.md)\>; `getGenerationContext`: () => `Promise`\<[`GenerationContext`](../type-aliases/GenerationContext.md)\>; `open`: (`extensionId`: `string`) => `void`; `setSwapValue`: (`value`: `string`) => `void`; \} + +##### fragmentSwapExtension.close() + +> **close**: () => `void` + +###### Returns + +`void` + +##### fragmentSwapExtension.getExperience() + +> **getExperience**: () => `Promise`\<[`Experience`](Experience.md)\> + +###### Returns + +`Promise`\<[`Experience`](Experience.md)\> + +##### fragmentSwapExtension.getGenerationContext() + +> **getGenerationContext**: () => `Promise`\<[`GenerationContext`](../type-aliases/GenerationContext.md)\> + +###### Returns + +`Promise`\<[`GenerationContext`](../type-aliases/GenerationContext.md)\> + +##### fragmentSwapExtension.open() + +> **open**: (`extensionId`: `string`) => `void` + +###### Parameters + +###### extensionId + +`string` + +###### Returns + +`void` + +##### fragmentSwapExtension.setSwapValue() + +> **setSwapValue**: (`value`: `string`) => `void` + +###### Parameters + +###### value + +`string` + +###### Returns + +`void` diff --git a/src/services/fragment-swap-extension-service.ts b/src/services/fragment-swap-extension-service.ts new file mode 100644 index 0000000..e49546c --- /dev/null +++ b/src/services/fragment-swap-extension-service.ts @@ -0,0 +1,153 @@ +/* +Copyright 2026 Adobe. All rights reserved. +This file is licensed to you 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 REPRESENTATIONS +OF ANY KIND, either express or implied. See the License for the specific language +governing permissions and limitations under the License. +*/ + +import { VirtualApi } from "@adobe/uix-core"; +import { Experience } from "../types/experience/Experience"; +import { GenerationContext } from "../types/generationContext/GenerationContext"; + +export interface FragmentSwapExtensionApi extends VirtualApi { + api: { + fragmentSwapExtension: { + open: (extensionId: string) => void; + close: () => void; + getExperience: () => Promise; + getGenerationContext: () => Promise; + setSwapValue: (value: string) => void; + }; + }; +} + +export class FragmentSwapExtensionServiceError extends Error { + constructor(message: string) { + super(message); + this.name = "FragmentSwapExtensionServiceError"; + } +} + +/** + * Manages swap field extension functionality for swapping field content + */ +export class FragmentSwapExtensionService { + /** + * Opens the swap field extension dialog + * @param connection - The guest connection to the host + * @param extensionId - The ID of the extension to open + * @throws Error if connection is missing + */ + static open(connection: any, extensionId: string): void { + if (!connection) { + throw new FragmentSwapExtensionServiceError( + "Connection is required to open swap field extension", + ); + } + + try { + // @ts-ignore Remote API is handled through postMessage + connection.host.api.fragmentSwapExtension.open(extensionId); + } catch (error) { + throw new FragmentSwapExtensionServiceError( + "Failed to open swap field extension", + ); + } + } + + /** + * Closes the swap field extension dialog + * @param connection - The guest connection to the host + * @throws Error if connection is missing + */ + static close(connection: any): void { + if (!connection) { + throw new FragmentSwapExtensionServiceError( + "Connection is required to close fragment swap extension", + ); + } + + try { + // @ts-ignore Remote API is handled through postMessage + connection.host.api.fragmentSwapExtension.close(); + } catch (error) { + throw new FragmentSwapExtensionServiceError( + "Failed to close fragment swap extension", + ); + } + } + + /** + * Gets the current field context from the host + * @param connection - The guest connection to the host + * @returns Promise The current field context + * @throws Error if connection is missing + */ + static async getExperience(connection: any): Promise { + if (!connection) { + throw new FragmentSwapExtensionServiceError( + "Connection is required to get experience", + ); + } + + try { + // @ts-ignore Remote API is handled through postMessage + return await connection.host.api.fragmentSwapExtension.getExperience(); + } catch (error) { + throw new FragmentSwapExtensionServiceError( + "Failed to get experience from host", + ); + } + } + + /** + * Gets the generation context from the host + * @param connection - The guest connection to the host + * @returns Promise The generation context + * @throws Error if connection is missing + */ + static async getGenerationContext(connection: any): Promise { + if (!connection) { + throw new FragmentSwapExtensionServiceError( + "Connection is required to get generation context", + ); + } + + try { + // @ts-ignore Remote API is handled through postMessage + return await connection.host.api.fragmentSwapExtension.getGenerationContext(); + } catch (error) { + throw new FragmentSwapExtensionServiceError( + "Failed to get generation context from host", + ); + } + } + + /** + * Sets the swap value for the field content + * @param connection - The guest connection to the host + * @param value - The new value to write into the field + * @throws Error if connection is missing + */ + static setSwapValue(connection: any, value: string): void { + if (!connection) { + throw new FragmentSwapExtensionServiceError( + "Connection is required to set swap value", + ); + } + + try { + // @ts-ignore Remote API is handled through postMessage + connection.host.api.fragmentSwapExtension.setSwapValue(value); + } catch (error) { + throw new FragmentSwapExtensionServiceError( + "Failed to set swap value", + ); + } + } +} diff --git a/src/services/index.ts b/src/services/index.ts index fadded0..871344e 100644 --- a/src/services/index.ts +++ b/src/services/index.ts @@ -15,3 +15,4 @@ export * from "./prompt-extension-service"; export * from "./select-content-extension-service"; export * from "./import-template-extension-service"; export * from "./extension-auth"; +export * from "./fragment-swap-extension-service"; diff --git a/tests/services/fragment-swap-extension-service.test.ts b/tests/services/fragment-swap-extension-service.test.ts new file mode 100644 index 0000000..ceb3ef8 --- /dev/null +++ b/tests/services/fragment-swap-extension-service.test.ts @@ -0,0 +1,306 @@ +/* +Copyright 2026 Adobe. All rights reserved. +This file is licensed to you 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 REPRESENTATIONS +OF ANY KIND, either express or implied. See the License for the specific language +governing permissions and limitations under the License. +*/ + +import { + FragmentSwapExtensionService, + FragmentSwapExtensionServiceError, + FragmentSwapExtensionApi, +} from "../../src/services"; +import { GuestUI } from "@adobe/uix-guest"; +import { GenerationContext } from "../../src/types/generationContext/GenerationContext"; + +type ConnectionMocks = { + openMock?: jest.Mock; + closeMock?: jest.Mock; + getExperienceMock?: jest.Mock; + getGenerationContextMock?: jest.Mock; + setSwapValueMock?: jest.Mock; +}; + +const createMockConnection = ({ + openMock, + closeMock, + getExperienceMock, + getGenerationContextMock, + setSwapValueMock, +}: ConnectionMocks = {}) => + ({ + host: { + api: { + fragmentSwapExtension: { + open: openMock || jest.fn(), + close: closeMock || jest.fn(), + getExperience: getExperienceMock || jest.fn(), + getGenerationContext: getGenerationContextMock || jest.fn(), + setSwapValue: setSwapValueMock || jest.fn(), + }, + }, + }, + }) as unknown as GuestUI; + +describe("FragmentSwapExtensionService", () => { + beforeEach(() => { + jest.spyOn(console, "error").mockImplementation(() => {}); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + const mockExperience = { + id: "exp123", + experienceFields: { + name: { + fieldName: "name", + fieldValue: "Test Experience", + }, + description: { + fieldName: "description", + fieldValue: "Test Description", + }, + }, + metadata: { + locale: "en-US", + random_key: "random_value", + }, + }; + + const mockGenerationContext: GenerationContext = { + id: "123", + userPrompt: "test-user-prompt", + }; + + describe("open", () => { + it("should open fragment swap extension successfully", () => { + const mockOpen = jest.fn(); + const mockConnection = createMockConnection({ openMock: mockOpen }); + const extensionId = "test-extension-id"; + + FragmentSwapExtensionService.open(mockConnection, extensionId); + + expect(mockOpen).toHaveBeenCalledWith(extensionId); + expect(mockOpen).toHaveBeenCalledTimes(1); + }); + + it("should throw FragmentSwapExtensionServiceError if connection is missing", () => { + const extensionId = "test-extension-id"; + + // @ts-ignore Testing null case explicitly + expect(() => FragmentSwapExtensionService.open(null, extensionId)).toThrow( + FragmentSwapExtensionServiceError, + ); + // @ts-ignore Testing null case explicitly + expect(() => FragmentSwapExtensionService.open(null, extensionId)).toThrow( + "Connection is required to open swap field extension", + ); + }); + + it("should throw FragmentSwapExtensionServiceError on API failure", () => { + const mockOpen = jest.fn().mockImplementation(() => { + throw new Error("API Error"); + }); + const mockConnection = createMockConnection({ openMock: mockOpen }); + const extensionId = "test-extension-id"; + + expect(() => + FragmentSwapExtensionService.open(mockConnection, extensionId), + ).toThrow(FragmentSwapExtensionServiceError); + expect(() => + FragmentSwapExtensionService.open(mockConnection, extensionId), + ).toThrow("Failed to open swap field extension"); + }); + + it("should handle empty extensionId", () => { + const mockOpen = jest.fn(); + const mockConnection = createMockConnection({ openMock: mockOpen }); + const extensionId = ""; + + FragmentSwapExtensionService.open(mockConnection, extensionId); + + expect(mockOpen).toHaveBeenCalledWith(extensionId); + }); + }); + + describe("close", () => { + it("should close fragment swap extension successfully", () => { + const mockClose = jest.fn(); + const mockConnection = createMockConnection({ closeMock: mockClose }); + + FragmentSwapExtensionService.close(mockConnection); + + expect(mockClose).toHaveBeenCalledTimes(1); + }); + + it("should throw FragmentSwapExtensionServiceError if connection is missing", () => { + // @ts-ignore Testing null case explicitly + expect(() => FragmentSwapExtensionService.close(null)).toThrow( + FragmentSwapExtensionServiceError, + ); + // @ts-ignore Testing null case explicitly + expect(() => FragmentSwapExtensionService.close(null)).toThrow( + "Connection is required to close fragment swap extension", + ); + }); + + it("should throw FragmentSwapExtensionServiceError on API failure", () => { + const mockClose = jest.fn().mockImplementation(() => { + throw new Error("API Error"); + }); + const mockConnection = createMockConnection({ closeMock: mockClose }); + + expect(() => + FragmentSwapExtensionService.close(mockConnection), + ).toThrow(FragmentSwapExtensionServiceError); + expect(() => + FragmentSwapExtensionService.close(mockConnection), + ).toThrow("Failed to close fragment swap extension"); + }); + }); + + describe("getExperience", () => { + it("should fetch experience", async () => { + const mockGetExperience = jest + .fn() + .mockResolvedValue(mockExperience); + const mockConnection = createMockConnection({ + getExperienceMock: mockGetExperience, + }); + + const result = + await FragmentSwapExtensionService.getExperience(mockConnection); + + expect(mockGetExperience).toHaveBeenCalled(); + expect(result.id).toBeDefined(); + expect(result.experienceFields).toBeDefined(); + expect(typeof result.experienceFields).toBe("object"); + expect(result.metadata).toBeDefined(); + expect(typeof result.metadata).toBe("object"); + }); + + it("should throw FragmentSwapExtensionServiceError on API failure", async () => { + const mockGetExperience = jest + .fn() + .mockRejectedValue(new Error("API Error")); + const mockConnection = createMockConnection({ + getExperienceMock: mockGetExperience, + }); + + await expect( + FragmentSwapExtensionService.getExperience(mockConnection), + ).rejects.toThrow(FragmentSwapExtensionServiceError); + await expect( + FragmentSwapExtensionService.getExperience(mockConnection), + ).rejects.toThrow("Failed to get experience from host"); + }); + + it("should throw FragmentSwapExtensionServiceError if connection is missing", async () => { + // @ts-ignore Testing null case explicitly + await expect( + FragmentSwapExtensionService.getExperience(null), + ).rejects.toThrow(FragmentSwapExtensionServiceError); + // @ts-ignore Testing null case explicitly + await expect( + FragmentSwapExtensionService.getExperience(null), + ).rejects.toThrow("Connection is required to get experience"); + }); + }); + + describe("getGenerationContext", () => { + it("should get generation context", async () => { + const mockGetGenerationContext = jest + .fn() + .mockResolvedValue(mockGenerationContext); + const mockConnection = createMockConnection({ + getGenerationContextMock: mockGetGenerationContext, + }); + const generationContext = + await FragmentSwapExtensionService.getGenerationContext(mockConnection); + expect(generationContext).toEqual(mockGenerationContext); + }); + + it("should throw FragmentSwapExtensionServiceError if connection is missing", async () => { + const connection = null; + await expect( + FragmentSwapExtensionService.getGenerationContext( + connection as unknown as GuestUI, + ), + ).rejects.toThrow( + new FragmentSwapExtensionServiceError( + "Connection is required to get generation context", + ), + ); + }); + + it("should throw FragmentSwapExtensionServiceError on API failure", async () => { + const mockGetGenerationContext = jest + .fn() + .mockRejectedValue(new Error("API Error")); + const mockConnection = createMockConnection({ + getGenerationContextMock: mockGetGenerationContext, + }); + await expect( + FragmentSwapExtensionService.getGenerationContext(mockConnection), + ).rejects.toThrow( + new FragmentSwapExtensionServiceError( + "Failed to get generation context from host", + ), + ); + }); + }); + + describe("setSwapValue", () => { + it("should call setSwapValue with the correct payload", () => { + const mockSetSwapValue = jest.fn(); + const mockConnection = createMockConnection({ setSwapValueMock: mockSetSwapValue }); + + FragmentSwapExtensionService.setSwapValue(mockConnection, "new headline text"); + + expect(mockSetSwapValue).toHaveBeenCalledWith("new headline text"); + expect(mockSetSwapValue).toHaveBeenCalledTimes(1); + }); + + it("should throw FragmentSwapExtensionServiceError if connection is missing", () => { + // @ts-ignore Testing null case explicitly + expect(() => FragmentSwapExtensionService.setSwapValue(null, "new headline text")).toThrow( + FragmentSwapExtensionServiceError, + ); + // @ts-ignore Testing null case explicitly + expect(() => FragmentSwapExtensionService.setSwapValue(null, "new headline text")).toThrow( + "Connection is required to set swap value", + ); + }); + + it("should throw FragmentSwapExtensionServiceError on API failure", () => { + const mockSetSwapValue = jest.fn().mockImplementation(() => { + throw new Error("API Error"); + }); + const mockConnection = createMockConnection({ setSwapValueMock: mockSetSwapValue }); + + expect(() => + FragmentSwapExtensionService.setSwapValue(mockConnection, "new headline text"), + ).toThrow(FragmentSwapExtensionServiceError); + expect(() => + FragmentSwapExtensionService.setSwapValue(mockConnection, "new headline text"), + ).toThrow("Failed to set swap value"); + }); + + it("should pass the full FieldUpdate payload to the host API", () => { + const mockSetSwapValue = jest.fn(); + const mockConnection = createMockConnection({ setSwapValueMock: mockSetSwapValue }); + + FragmentSwapExtensionService.setSwapValue(mockConnection, "new headline text"); + + expect(mockSetSwapValue).toHaveBeenCalledTimes(1); + expect(mockSetSwapValue).toHaveBeenCalledWith("new headline text"); + }); + }); +});