From 78c06dfafb921edeb77656db4a3c6371a9dce8d4 Mon Sep 17 00:00:00 2001 From: Erick Wendel Date: Wed, 26 Aug 2026 15:57:17 -0300 Subject: [PATCH 1/2] feat(webmcp): add authoring lifecycle and slide upserts --- apps/editor/src/domain/documents/model.ts | 12 + .../authoringAutomationController.ts | 337 +++++++++++ .../automation/authoringOperationRegistry.ts | 107 ++++ .../createAuthoringAutomationDelegate.ts | 249 ++++++++ .../services/automation/slideUpsertService.ts | 546 ++++++++++++++++++ .../services/webmcp/slideUpsertInputSchema.ts | 276 +++++++++ .../src/services/webmcp/webMcpToolAdapter.ts | 451 ++++++++++++--- .../src/ui/editor/shell/EditorShell.tsx | 25 +- .../src/ui/editor/state/useEditorViewModel.ts | 9 + .../src/ui/webmcp/WebMcpShowcasePage.tsx | 160 ++--- apps/editor/tests/unit/app/App.test.tsx | 108 ++-- .../authoringAutomationController.test.ts | 171 ++++++ .../createAuthoringAutomationDelegate.test.ts | 158 +++++ .../unit/services/slideUpsertService.test.ts | 151 +++++ .../unit/services/webMcpToolAdapter.test.ts | 147 +++-- .../tests/unit/ui/editor/EditorShell.test.tsx | 45 +- .../editor/coverage-service-contracts.spec.ts | 93 +-- .../editor/service-contracts-webmcp.spec.ts | 75 ++- ...tool-adapter-execution-contract-browser.ts | 117 ++-- ...-tool-adapter-metadata-contract-browser.ts | 39 +- ...l-adapter-registration-contract-browser.ts | 46 +- tests/e2e/webmcp/discover-tools.spec.ts | 35 +- 22 files changed, 2918 insertions(+), 439 deletions(-) create mode 100644 apps/editor/src/services/automation/authoringAutomationController.ts create mode 100644 apps/editor/src/services/automation/authoringOperationRegistry.ts create mode 100644 apps/editor/src/services/automation/createAuthoringAutomationDelegate.ts create mode 100644 apps/editor/src/services/automation/slideUpsertService.ts create mode 100644 apps/editor/src/services/webmcp/slideUpsertInputSchema.ts create mode 100644 apps/editor/tests/unit/services/authoringAutomationController.test.ts create mode 100644 apps/editor/tests/unit/services/createAuthoringAutomationDelegate.test.ts create mode 100644 apps/editor/tests/unit/services/slideUpsertService.test.ts diff --git a/apps/editor/src/domain/documents/model.ts b/apps/editor/src/domain/documents/model.ts index 6ec6ce60..e553199d 100644 --- a/apps/editor/src/domain/documents/model.ts +++ b/apps/editor/src/domain/documents/model.ts @@ -76,9 +76,20 @@ export interface Page { animationBuilds?: ElementAnimationBuild[]; layoutId?: string; speakerNotes?: string; + semanticDescription?: SemanticSlideDescription; visible?: boolean; } +export interface SemanticSlideDescription { + text: string; + language: string; + generatedAt: string; + generator: string; + sourceRevision: string; + reviewed: boolean; + stale: boolean; +} + export type PageBackground = | { type: 'color'; color: string } | { type: 'asset'; assetId: string; colorFallback: string }; @@ -140,6 +151,7 @@ export interface ElementAnimationBuild { direction?: AnimationDirection; durationMs?: number; kind?: ElementAnimationKind; + order?: number; lineDrawDirection?: AnimationLineDrawDirection; mediaAction?: 'play'; } diff --git a/apps/editor/src/services/automation/authoringAutomationController.ts b/apps/editor/src/services/automation/authoringAutomationController.ts new file mode 100644 index 00000000..72844055 --- /dev/null +++ b/apps/editor/src/services/automation/authoringAutomationController.ts @@ -0,0 +1,337 @@ +import type { ModelState } from '../contracts/interfaces'; +import type { SlideUpsertBatch, SlideUpsertResult } from './slideUpsertService'; +import { + AuthoringOperationRegistry, + type AuthoringOperationProgress, +} from './authoringOperationRegistry'; + +export type AuthoringResult = + | { ok: true; data: T } + | { ok: false; errorCode: string; message: string }; + +export interface AuthoringProgressReporter { + (progress: Partial): void; +} + +export interface AuthoringAutomationDelegate { + createPresentation(input: { + name?: string | undefined; + width?: number | undefined; + height?: number | undefined; + }): unknown; + getPresentationState(input: { + detail?: 'summary' | 'elements' | undefined; + slideNumbers?: number[] | undefined; + cursor?: number | undefined; + elementCursor?: number | undefined; + elementLimit?: number | undefined; + }): unknown; + importPowerPointFromUrl?( + input: { url: string; fileName?: string | undefined }, + report: AuthoringProgressReporter, + ): Promise; + translateDeckAndNotes?( + input: { targetLanguage: string; sourceLanguage?: string | undefined }, + report: AuthoringProgressReporter, + ): Promise; + generateDeckDetailedDescription?( + input: { + slideNumbers?: number[] | undefined; + language?: string | undefined; + force?: boolean | undefined; + }, + report: AuthoringProgressReporter, + ): Promise; + listAuthoringCatalog?(input: { + kind: 'fonts' | 'animations'; + elementType?: 'text' | 'image' | 'gif' | 'video' | 'shape' | undefined; + }): unknown; + upsertSlideContent(input: SlideUpsertBatch): Promise; + generateImage?( + input: { + prompt: string; + width?: number | undefined; + height?: number | undefined; + seed?: number | undefined; + steps?: number | undefined; + }, + report: AuthoringProgressReporter, + ): Promise; + getSlidePreview?(input: { slideNumber: number }): unknown; + getAiModelStatus?(): Promise; + prepareAiModels?( + input: { modelIds?: string[] | undefined }, + report: AuthoringProgressReporter, + ): Promise; + searchMedia?(input: { + kind: 'image' | 'gif'; + term: string; + limit?: number | undefined; + }): Promise; + exportPresentation?( + input: { + format: 'pptx' | 'pdf' | 'png' | 'jpeg'; + slideRange?: 'all' | 'current' | undefined; + includeAnimationFrames?: boolean | undefined; + }, + report: AuthoringProgressReporter, + ): Promise; + publishPresentation?( + input: { shareId?: string | undefined }, + report: AuthoringProgressReporter, + ): Promise; +} + +function success(data: T): AuthoringResult { + return { ok: true, data }; +} + +function failure(errorCode: string, message: string): AuthoringResult { + return { ok: false, errorCode, message }; +} + +function operationStarted(status: { operationId: string; state: string }) { + return success({ operationId: status.operationId, status: status.state }); +} + +function canonicalize(value: unknown): unknown { + if (Array.isArray(value)) return value.map(canonicalize); + if (!value || typeof value !== 'object') return value; + return Object.fromEntries( + Object.entries(value) + .filter(([, entry]) => entry !== undefined) + .sort(([first], [second]) => first.localeCompare(second)) + .map(([key, entry]) => [key, canonicalize(entry)]), + ); +} + +class AuthoringAutomationController { + private readonly operations = new AuthoringOperationRegistry(); + private readonly upserts = new Map< + string, + { inputKey: string; promise: Promise } + >(); + + constructor(private readonly delegate: AuthoringAutomationDelegate) {} + + async createPresentation(input: { + name?: string | undefined; + width?: number | undefined; + height?: number | undefined; + }): Promise> { + try { + return success(await this.delegate.createPresentation(input)); + } catch (error) { + return failure('create_presentation', this.describeError(error)); + } + } + + async getPresentationState(input: { + detail?: 'summary' | 'elements' | undefined; + slideNumbers?: number[] | undefined; + cursor?: number | undefined; + elementCursor?: number | undefined; + elementLimit?: number | undefined; + }): Promise> { + try { + return success(await this.delegate.getPresentationState(input)); + } catch (error) { + return failure('get_presentation_state', this.describeError(error)); + } + } + + importPowerPointFromUrl(input: { url: string; fileName?: string | undefined }) { + if (!input.url.trim()) return failure('invalid_url', 'A PowerPoint URL is required.'); + if (!this.delegate.importPowerPointFromUrl) + return this.pending('import_powerpoint_from_url', 173); + const run = this.delegate.importPowerPointFromUrl.bind(this.delegate); + return operationStarted( + this.operations.start('fetching-powerpoint', (report) => run(input, report)), + ); + } + + translateDeckAndNotes(input: { targetLanguage: string; sourceLanguage?: string | undefined }) { + if (!input.targetLanguage.trim()) { + return failure('invalid_target_language', 'A target language is required.'); + } + if (!this.delegate.translateDeckAndNotes) return this.pending('translate_deck_and_notes', 174); + const run = this.delegate.translateDeckAndNotes.bind(this.delegate); + return operationStarted( + this.operations.start('preparing-translation', (report) => run(input, report)), + ); + } + + generateDeckDetailedDescription(input: { + slideNumbers?: number[] | undefined; + language?: string | undefined; + force?: boolean | undefined; + }) { + if (!this.delegate.generateDeckDetailedDescription) { + return this.pending('generate_deck_detailed_description', 174); + } + const run = this.delegate.generateDeckDetailedDescription.bind(this.delegate); + return operationStarted( + this.operations.start('describing-slides', (report) => run(input, report)), + ); + } + + async listAuthoringCatalog(input: { + kind: 'fonts' | 'animations'; + elementType?: 'text' | 'image' | 'gif' | 'video' | 'shape' | undefined; + }): Promise> { + if (!['fonts', 'animations'].includes(input.kind)) { + return failure('invalid_catalog', 'Catalog kind must be fonts or animations.'); + } + if (input.kind === 'animations' && !input.elementType) { + return failure('missing_element_type', 'Animation discovery requires elementType.'); + } + if (!this.delegate.listAuthoringCatalog) return this.pending('list_authoring_catalog', 175); + try { + return success(await this.delegate.listAuthoringCatalog(input)); + } catch (error) { + return failure('list_authoring_catalog', this.describeError(error)); + } + } + + async upsertSlideContent(input: SlideUpsertBatch): Promise> { + const cached = this.upserts.get(input.requestId); + const inputKey = JSON.stringify(canonicalize(input)); + if (cached) { + if (cached.inputKey !== inputKey) { + return failure( + 'request_id_conflict', + `requestId ${input.requestId} was already used with a different batch.`, + ); + } + try { + const result = await cached.promise; + return success({ ...result, project: undefined, idempotentReplay: true }); + } catch (error) { + return failure('upsert_slide_content', this.describeError(error)); + } + } + const promise = this.delegate.upsertSlideContent(input); + this.upserts.set(input.requestId, { inputKey, promise }); + try { + const result = await promise; + return success({ ...result, project: undefined, idempotentReplay: false }); + } catch (error) { + this.upserts.delete(input.requestId); + return failure('upsert_slide_content', this.describeError(error)); + } + } + + generateImage(input: { + prompt: string; + width?: number | undefined; + height?: number | undefined; + seed?: number | undefined; + steps?: number | undefined; + }) { + if (!input.prompt.trim()) return failure('empty_prompt', 'An image prompt is required.'); + if (!this.delegate.generateImage) return this.pending('generate_image', 175); + const run = this.delegate.generateImage.bind(this.delegate); + return operationStarted( + this.operations.start('generating-image', (report) => run(input, report)), + ); + } + + async getSlidePreview(input: { slideNumber: number }): Promise> { + if (!this.delegate.getSlidePreview) return this.pending('get_slide_preview', 176); + try { + return success(await this.delegate.getSlidePreview(input)); + } catch (error) { + return failure('get_slide_preview', this.describeError(error)); + } + } + + async getAiModelStatus(): Promise> { + if (!this.delegate.getAiModelStatus) return this.pending('get_ai_model_status', 175); + try { + return success(await this.delegate.getAiModelStatus()); + } catch (error) { + return failure('get_ai_model_status', this.describeError(error)); + } + } + + prepareAiModels(input: { modelIds?: string[] | undefined }) { + if (!this.delegate.prepareAiModels) return this.pending('prepare_ai_models', 175); + const run = this.delegate.prepareAiModels.bind(this.delegate); + return operationStarted( + this.operations.start('preparing-ai-models', (report) => run(input, report)), + ); + } + + async searchMedia(input: { + kind: 'image' | 'gif'; + term: string; + limit?: number | undefined; + }): Promise> { + if (!['image', 'gif'].includes(input.kind)) { + return failure('invalid_media_kind', 'Media kind must be image or gif.'); + } + if (!this.delegate.searchMedia) return this.pending('search_media', 175); + try { + return success(await this.delegate.searchMedia(input)); + } catch (error) { + return failure('search_media', this.describeError(error)); + } + } + + exportPresentation(input: { + format: 'pptx' | 'pdf' | 'png' | 'jpeg'; + slideRange?: 'all' | 'current' | undefined; + includeAnimationFrames?: boolean | undefined; + }) { + if (!['pptx', 'pdf', 'png', 'jpeg'].includes(input.format)) { + return failure('invalid_export_format', 'Export format must be pptx, pdf, png, or jpeg.'); + } + if (!this.delegate.exportPresentation) return this.pending('export_presentation', 176); + const run = this.delegate.exportPresentation.bind(this.delegate); + return operationStarted( + this.operations.start('exporting-presentation', (report) => run(input, report)), + ); + } + + publishPresentation(input: { shareId?: string | undefined }) { + if (!this.delegate.publishPresentation) return this.pending('publish_presentation', 177); + const run = this.delegate.publishPresentation.bind(this.delegate); + return operationStarted( + this.operations.start('publishing-presentation', (report) => run(input, report)), + ); + } + + async getOperationStatus(input: { + operationId: string; + waitForChangeMs?: number | undefined; + }): Promise> { + const initial = this.operations.get(input.operationId); + if (!initial) return failure('unknown_operation', `Unknown operation: ${input.operationId}.`); + const waitMs = Math.max(0, Math.min(5_000, input.waitForChangeMs ?? 0)); + if (waitMs > 0 && ['queued', 'running'].includes(initial.state)) { + const revision = initial.revision; + const deadline = Date.now() + waitMs; + while (Date.now() < deadline) { + await new Promise((resolve) => + globalThis.setTimeout(resolve, Math.min(100, deadline - Date.now())), + ); + const next = this.operations.get(input.operationId); + if (!next || next.revision !== revision) break; + } + } + return success(this.operations.get(input.operationId)); + } + + private describeError(error: unknown) { + return error instanceof Error ? error.message : 'Authoring action failed.'; + } + + private pending(toolName: string, issueNumber: number): AuthoringResult { + return failure( + 'capability_pending', + `${toolName} is reserved in the authoring catalog and will be implemented in #${issueNumber}.`, + ); + } +} + +export const authoringAutomationController = { AuthoringAutomationController }; diff --git a/apps/editor/src/services/automation/authoringOperationRegistry.ts b/apps/editor/src/services/automation/authoringOperationRegistry.ts new file mode 100644 index 00000000..2d0a079d --- /dev/null +++ b/apps/editor/src/services/automation/authoringOperationRegistry.ts @@ -0,0 +1,107 @@ +export type AuthoringOperationState = 'queued' | 'running' | 'completed' | 'failed'; + +export interface AuthoringOperationProgress { + stage: string; + progress: number; + current?: number; + total?: number; + loadedBytes?: number; + totalBytes?: number; + detail?: string; + warnings?: string[]; +} + +export interface AuthoringOperationStatus extends AuthoringOperationProgress { + operationId: string; + percentage: number; + revision: number; + state: AuthoringOperationState; + createdAt: string; + updatedAt: string; + warnings: string[]; + error?: string; + result?: unknown; +} + +type OperationTask = ( + report: (progress: Partial) => void, +) => Promise; + +function createOperationId() { + if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') { + return `operation-${crypto.randomUUID()}`; + } + return `operation-${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`; +} + +function cloneStatus(status: AuthoringOperationStatus): AuthoringOperationStatus { + return { + ...status, + warnings: [...status.warnings], + }; +} + +export class AuthoringOperationRegistry { + private readonly operations = new Map(); + + start(stage: string, task: OperationTask): AuthoringOperationStatus { + const operationId = createOperationId(); + const timestamp = new Date().toISOString(); + const status: AuthoringOperationStatus = { + operationId, + percentage: 0, + revision: 0, + state: 'queued', + stage, + progress: 0, + createdAt: timestamp, + updatedAt: timestamp, + warnings: [], + }; + this.operations.set(operationId, status); + + void Promise.resolve().then(async () => { + this.update(operationId, { state: 'running', progress: 1 }); + try { + const result = await task((progress) => this.update(operationId, progress)); + this.update(operationId, { + state: 'completed', + stage: 'completed', + progress: 100, + result, + }); + } catch (error) { + this.update(operationId, { + state: 'failed', + stage: 'failed', + error: error instanceof Error ? error.message : 'Authoring operation failed.', + }); + } + }); + + return cloneStatus(status); + } + + get(operationId: string): AuthoringOperationStatus | undefined { + const status = this.operations.get(operationId); + return status ? cloneStatus(status) : undefined; + } + + private update(operationId: string, patch: Partial) { + const current = this.operations.get(operationId); + if (!current) return; + const progress = + patch.progress === undefined + ? current.progress + : Math.max(current.progress, Math.min(100, Math.round(patch.progress))); + this.operations.set(operationId, { + ...current, + ...patch, + progress, + percentage: progress, + revision: current.revision + 1, + updatedAt: new Date().toISOString(), + warnings: patch.warnings ? [...patch.warnings] : current.warnings, + }); + } +} diff --git a/apps/editor/src/services/automation/createAuthoringAutomationDelegate.ts b/apps/editor/src/services/automation/createAuthoringAutomationDelegate.ts new file mode 100644 index 00000000..fbe35e32 --- /dev/null +++ b/apps/editor/src/services/automation/createAuthoringAutomationDelegate.ts @@ -0,0 +1,249 @@ +import type { Asset, ProjectDocument } from '../../domain/documents/model'; +import { sampleProject } from '../../domain/projects/sampleProject'; +import type { FontImportService } from '../contracts/interfaces'; +import { createPrefixedId } from '../ids/idUtils'; +import type { AuthoringAutomationDelegate } from './authoringAutomationController'; +import { + slideUpsertService, + type SlideMediaContentInput, + type SlideUpsertBatch, +} from './slideUpsertService'; + +interface CreateAuthoringDelegateOptions { + fontImportService: FontImportService; + getProject(): ProjectDocument; + replaceProject(project: ProjectDocument): void; + applyProject(project: ProjectDocument, activePageId?: string): void; +} + +const builtInFonts = new Set(['arial', 'inter', 'open sans', 'orbitron']); +const maxStateTextLength = 4_000; + +function boundedText(value: string | undefined) { + if (!value || value.length <= maxStateTextLength) return value; + return `${value.slice(0, maxStateTextLength)}…`; +} + +function boundedElement(element: ProjectDocument['elements'][string] | undefined) { + if (!element) return undefined; + return element.type === 'text' ? { ...element, text: boundedText(element.text) } : element; +} + +function validateRemoteUrl(value: string) { + let url: URL; + try { + url = new URL(value); + } catch { + throw new Error('Media URLs must be valid absolute URLs.'); + } + if (!['http:', 'https:'].includes(url.protocol)) { + throw new Error('Only HTTP and HTTPS media URLs are supported.'); + } + return url.toString(); +} + +function getSlideRevision(project: ProjectDocument, pageId: string) { + const page = project.pages.find((candidate) => candidate.id === pageId); + if (!page) return ''; + const value = JSON.stringify({ + page: { + width: page.width, + height: page.height, + background: page.background, + elementIds: page.elementIds, + speakerNotes: page.speakerNotes, + }, + elements: page.elementIds.map((elementId) => project.elements[elementId]), + }); + let hash = 2166136261; + for (let index = 0; index < value.length; index += 1) { + hash ^= value.charCodeAt(index); + hash = Math.imul(hash, 16777619); + } + return `slide-${(hash >>> 0).toString(16)}`; +} + +function createPresentationState( + project: ProjectDocument, + input: Parameters[0], +) { + const requestedNumbers = input.slideNumbers?.filter( + (slideNumber) => Number.isInteger(slideNumber) && slideNumber > 0, + ); + const cursor = Math.max(0, input.cursor ?? 0); + const candidates = requestedNumbers?.length + ? requestedNumbers + .map((slideNumber) => ({ page: project.pages[slideNumber - 1], slideNumber })) + .filter((entry): entry is { page: ProjectDocument['pages'][number]; slideNumber: number } => + Boolean(entry.page), + ) + : project.pages + .map((page, index) => ({ page, slideNumber: index + 1 })) + .slice(cursor, cursor + 20); + const detailed = input.detail === 'elements'; + const elementCursor = Math.floor(Math.max(0, input.elementCursor ?? 0)); + const elementLimit = Math.floor(Math.max(1, Math.min(50, input.elementLimit ?? 25))); + const slides = candidates.slice(0, detailed ? 5 : 20).map(({ page, slideNumber }) => ({ + slideId: page.id, + slideNumber, + name: page.name, + width: page.width, + height: page.height, + background: page.background, + elementCount: page.elementIds.length, + speakerNotes: boundedText(page.speakerNotes), + semanticDescription: page.semanticDescription + ? { ...page.semanticDescription, text: boundedText(page.semanticDescription.text) } + : undefined, + descriptionFreshness: page.semanticDescription + ? page.semanticDescription.stale + ? 'stale' + : 'fresh' + : 'missing', + revision: getSlideRevision(project, page.id), + ...(detailed + ? { + elements: page.elementIds + .slice(elementCursor, elementCursor + elementLimit) + .map((elementId) => boundedElement(project.elements[elementId])) + .filter(Boolean), + nextElementCursor: + elementCursor + elementLimit < page.elementIds.length + ? elementCursor + elementLimit + : undefined, + } + : {}), + })); + return { + projectId: project.id, + name: project.name, + updatedAt: project.updatedAt, + revision: `${project.id}:${project.updatedAt}`, + pageCount: project.pages.length, + assetCount: Object.keys(project.assets).length, + recordingCount: Object.keys(project.recordings ?? {}).length, + slides, + nextCursor: + !requestedNumbers?.length && cursor + slides.length < project.pages.length + ? cursor + slides.length + : undefined, + }; +} + +export function createAuthoringAutomationDelegate( + options: CreateAuthoringDelegateOptions, +): AuthoringAutomationDelegate { + function resolveMedia( + input: SlideMediaContentInput, + context: { elementId: string; type: 'gif' | 'image' | 'video' }, + ): Promise { + if (input.assetId) { + const asset = options.getProject().assets[input.assetId]; + if (!asset) throw new Error(`Unknown assetId: ${input.assetId}.`); + if (asset.type !== context.type && !(context.type === 'image' && asset.type === 'gif')) { + throw new Error(`Asset ${input.assetId} cannot be used as ${context.type}.`); + } + return Promise.resolve(asset); + } + if (input.mediaRef) { + throw new Error('mediaRef insertion will be enabled with search_media in #175.'); + } + if (!input.url) throw new Error(`${context.elementId} needs assetId or url.`); + return Promise.resolve({ + id: `asset-${context.elementId}`, + type: context.type, + name: `${context.type} for ${context.elementId}`, + mimeType: + context.type === 'video' + ? 'video/mp4' + : context.type === 'gif' + ? 'image/gif' + : 'image/jpeg', + objectUrl: validateRemoteUrl(input.url), + storage: 'remote', + }); + } + + return { + createPresentation(input) { + const width = input.width ?? 1920; + const height = input.height ?? 1080; + if (!Number.isFinite(width) || width < 1 || !Number.isFinite(height) || height < 1) { + throw new Error('Presentation dimensions must be positive finite numbers.'); + } + const blank = sampleProject.createBlankProject(); + const project = { + ...blank, + name: input.name?.trim() || blank.name, + pages: blank.pages.map((page) => ({ ...page, width, height })), + updatedAt: new Date().toISOString(), + }; + options.replaceProject(project); + return { + projectId: project.id, + name: project.name, + slideId: project.pages[0]?.id, + width, + height, + }; + }, + + getPresentationState(input) { + return createPresentationState(options.getProject(), input); + }, + + async upsertSlideContent(batch: SlideUpsertBatch) { + let project = options.getProject(); + slideUpsertService.validate(project, batch); + const requestedFonts = [ + ...new Map( + batch.elements + .filter((element) => element.type === 'text') + .map((element) => [ + `${element.style.fontFamily.trim().toLowerCase()}:${element.style.fontWeight}`, + { family: element.style.fontFamily.trim(), fontWeight: element.style.fontWeight }, + ]), + ).values(), + ].filter(({ family }) => family); + const projectFonts = Object.values(project.fonts ?? {}); + const missingFonts = requestedFonts.filter( + ({ family, fontWeight }) => + !builtInFonts.has(family.toLowerCase()) && + !projectFonts.some( + (font) => + font.family.toLowerCase() === family.toLowerCase() && font.fontWeight === fontWeight, + ), + ); + if (missingFonts.length) { + const fontResult = await options.fontImportService.resolveAndDownloadFonts( + missingFonts.map(({ family, fontWeight }) => ({ + family, + fontStyle: 'normal', + fontWeight, + })), + ); + const usableStatuses = new Set([ + 'available-system', + 'downloaded-exact', + 'downloaded-compatible', + ]); + const unresolved = fontResult.resolutions.filter( + (resolution) => !usableStatuses.has(resolution.status), + ); + if (unresolved.length) { + throw new Error( + `Fonts unavailable: ${unresolved.map((font) => font.requestedFamily).join(', ')}.`, + ); + } + project = { ...project, fonts: { ...(project.fonts ?? {}), ...fontResult.fonts } }; + await options.fontImportService.loadProjectFonts(project); + } + const result = await slideUpsertService.apply(project, batch, { + createId: createPrefixedId, + resolveMedia, + }); + options.applyProject(result.project, result.slideId); + return result; + }, + }; +} diff --git a/apps/editor/src/services/automation/slideUpsertService.ts b/apps/editor/src/services/automation/slideUpsertService.ts new file mode 100644 index 00000000..dc48dfe3 --- /dev/null +++ b/apps/editor/src/services/automation/slideUpsertService.ts @@ -0,0 +1,546 @@ +import type { + Asset, + CropRect, + DesignElement, + ElementAnimationBuild, + ElementAnimationKind, + ElementType, + PageBackground, + ProjectDocument, + ShapeKind, +} from '../../domain/documents/model'; + +export interface SlideElementFrameInput { + x: number; + y: number; + width: number; + height: number; +} + +export interface SlideElementAnimationInput { + effect: ElementAnimationBuild['effect']; + trigger?: ElementAnimationBuild['trigger']; + kind?: ElementAnimationKind; + delayMs?: number; + durationMs?: number; + direction?: ElementAnimationBuild['direction']; + order: number; +} + +interface SlideElementBaseInput { + elementId: string; + type: ElementType; + frame: SlideElementFrameInput; + zIndex: number; + rotation?: number; + opacity?: number; + visible?: boolean; + locked?: boolean; + animations?: SlideElementAnimationInput[]; +} + +export interface SlideTextElementInput extends SlideElementBaseInput { + type: 'text'; + content: { text: string }; + style: { + fontFamily: string; + fontSize: number; + fontWeight: number; + color: string; + align?: 'left' | 'center' | 'right'; + verticalAlign?: 'bottom' | 'middle' | 'top'; + lineHeight?: number; + highlight?: string; + }; +} + +export interface SlideMediaContentInput { + assetId?: string; + url?: string; + mediaRef?: string; +} + +export interface SlideImageElementInput extends SlideElementBaseInput { + type: 'image'; + content: SlideMediaContentInput; + crop?: CropRect; + flipX?: boolean; + mask?: 'ellipse'; +} + +export interface SlideGifElementInput extends SlideElementBaseInput { + type: 'gif'; + content: SlideMediaContentInput; + playing?: boolean; +} + +export interface SlideVideoElementInput extends SlideElementBaseInput { + type: 'video'; + content: SlideMediaContentInput; + playback?: { + loop?: boolean; + controls?: boolean; + muted?: boolean; + autoplayInPreview?: boolean; + trimStartSeconds?: number; + trimEndSeconds?: number; + playAcrossSlides?: boolean; + startOnClick?: boolean; + volume?: number; + }; +} + +export interface SlideShapeElementInput extends SlideElementBaseInput { + type: 'shape'; + content: { + shape: ShapeKind; + fill?: string; + stroke?: string; + strokeWidth?: number; + }; +} + +export type SlideElementInput = + | SlideTextElementInput + | SlideImageElementInput + | SlideGifElementInput + | SlideVideoElementInput + | SlideShapeElementInput; + +export interface SlideUpsertBatch { + requestId: string; + slideId?: string; + slideNumber?: number; + mode: 'merge' | 'replace'; + slide?: { + name?: string; + width?: number; + height?: number; + background?: PageBackground; + speakerNotes?: string; + }; + elements: SlideElementInput[]; + deleteElementIds?: string[]; +} + +export interface SlideUpsertResult { + requestId: string; + slideId: string; + slideNumber: number; + createdSlide: boolean; + createdElements: number; + updatedElements: number; + deletedElements: number; + elementCount: number; + project: ProjectDocument; +} + +interface SlideUpsertOptions { + createId(prefix: string): string; + resolveMedia( + input: SlideMediaContentInput, + context: { elementId: string; type: 'gif' | 'image' | 'video' }, + ): Promise; +} + +const shapeKinds = new Set([ + 'arc', + 'arrow', + 'diamond', + 'ellipse', + 'line', + 'parallelogram', + 'pentagon', + 'rect', + 'rounded-rect', + 'triangle', +]); +const animationEffects = new Set([ + 'blinds', + 'clothesline', + 'color-planes', + 'confetti', + 'cube', + 'doorway', + 'dissolve', + 'drop', + 'droplet', + 'fade', + 'fade-and-move', + 'fade-through-color', + 'fall', + 'flip', + 'flop', + 'grid', + 'iris', + 'keyboard-typing', + 'line-draw', + 'mosaic', + 'move-in', + 'page-flip', + 'pivot', + 'push', + 'radial-wipe', + 'reflection', + 'reveal', + 'revolving-door', + 'scale', + 'swap', + 'switch', + 'swoosh', + 'twirl', + 'twist', + 'wipe', +]); +const elementTypes = new Set(['text', 'image', 'gif', 'video', 'shape']); +const animationTriggers = new Set(['on-click', 'after-transition', 'after-previous']); +const animationKinds = new Set(['build-in', 'build-out', 'emphasis']); +const animationDirections = new Set(['up', 'right', 'down', 'left']); + +function requireFinite(value: number, label: string, minimum?: number) { + if (!Number.isFinite(value) || (minimum !== undefined && value < minimum)) { + throw new Error( + `${label} must be a finite number${minimum === undefined ? '' : ` greater than or equal to ${minimum}`}.`, + ); + } +} + +function validateElement(input: SlideElementInput) { + if (!input.elementId.trim()) throw new Error('Every element needs a stable elementId.'); + if (!elementTypes.has(input.type)) + throw new Error(`${input.elementId} uses an unsupported type.`); + requireFinite(input.frame.x, `${input.elementId}.frame.x`); + requireFinite(input.frame.y, `${input.elementId}.frame.y`); + requireFinite(input.frame.width, `${input.elementId}.frame.width`, 1); + requireFinite(input.frame.height, `${input.elementId}.frame.height`, 1); + requireFinite(input.zIndex, `${input.elementId}.zIndex`, 0); + if (!Number.isInteger(input.zIndex)) + throw new Error(`${input.elementId}.zIndex must be an integer.`); + if (input.opacity !== undefined) { + requireFinite(input.opacity, `${input.elementId}.opacity`, 0); + if (input.opacity > 1) throw new Error(`${input.elementId}.opacity must be at most 1.`); + } + for (const animation of input.animations ?? []) { + if (!animationEffects.has(animation.effect)) { + throw new Error(`${input.elementId} uses an unsupported animation effect.`); + } + requireFinite(animation.order, `${input.elementId}.animation.order`, 0); + if (!Number.isInteger(animation.order)) { + throw new Error(`${input.elementId}.animation.order must be an integer.`); + } + if (animation.trigger && !animationTriggers.has(animation.trigger)) { + throw new Error(`${input.elementId} uses an unsupported animation trigger.`); + } + if (animation.kind && !animationKinds.has(animation.kind)) { + throw new Error(`${input.elementId} uses an unsupported animation kind.`); + } + if (animation.direction && !animationDirections.has(animation.direction)) { + throw new Error(`${input.elementId} uses an unsupported animation direction.`); + } + if (animation.delayMs !== undefined) { + requireFinite(animation.delayMs, `${input.elementId}.animation.delayMs`, 0); + } + if (animation.durationMs !== undefined) { + requireFinite(animation.durationMs, `${input.elementId}.animation.durationMs`, 0); + } + } + if (input.type === 'text') { + if (!input.style.fontFamily.trim()) throw new Error(`${input.elementId} needs a font family.`); + requireFinite(input.style.fontSize, `${input.elementId}.style.fontSize`, 1); + requireFinite(input.style.fontWeight, `${input.elementId}.style.fontWeight`, 1); + } + if (input.type === 'shape' && !shapeKinds.has(input.content.shape)) { + throw new Error(`${input.elementId} uses an unsupported shape.`); + } + if (input.type === 'image' || input.type === 'gif' || input.type === 'video') { + const sources = [input.content.assetId, input.content.url, input.content.mediaRef].filter( + Boolean, + ); + if (sources.length !== 1) { + throw new Error(`${input.elementId} needs exactly one of assetId, url, or mediaRef.`); + } + } +} + +function commonElement(input: SlideElementInput) { + return { + id: input.elementId, + type: input.type, + ...input.frame, + rotation: input.rotation ?? 0, + opacity: Math.min(1, input.opacity ?? 1), + visible: input.visible ?? true, + locked: input.locked ?? false, + }; +} + +async function createElement( + input: SlideElementInput, + options: SlideUpsertOptions, +): Promise<{ asset?: Asset; element: DesignElement }> { + const common = commonElement(input); + if (input.type === 'text') { + return { + element: { + ...common, + type: 'text', + text: input.content.text, + fontFamily: input.style.fontFamily, + fontSize: input.style.fontSize, + fontWeight: input.style.fontWeight, + fill: input.style.color, + align: input.style.align ?? 'left', + ...(input.style.verticalAlign ? { verticalAlign: input.style.verticalAlign } : {}), + ...(input.style.lineHeight !== undefined ? { lineHeight: input.style.lineHeight } : {}), + ...(input.style.highlight ? { highlight: input.style.highlight } : {}), + }, + }; + } + if (input.type === 'shape') { + return { + element: { + ...common, + type: 'shape', + shape: input.content.shape, + ...(input.content.fill ? { fill: input.content.fill } : {}), + ...(input.content.stroke ? { stroke: input.content.stroke } : {}), + ...(input.content.strokeWidth !== undefined + ? { strokeWidth: input.content.strokeWidth } + : {}), + }, + }; + } + + const asset = await options.resolveMedia(input.content, { + elementId: input.elementId, + type: input.type, + }); + if (input.type === 'image') { + return { + asset, + element: { + ...common, + type: 'image', + assetId: asset.id, + ...(input.crop ? { crop: input.crop } : {}), + ...(input.flipX !== undefined ? { flipX: input.flipX } : {}), + ...(input.mask ? { mask: input.mask } : {}), + }, + }; + } + if (input.type === 'gif') { + return { + asset, + element: { + ...common, + type: 'gif', + assetId: asset.id, + playing: input.playing ?? true, + }, + }; + } + return { + asset, + element: { + ...common, + type: 'video', + assetId: asset.id, + loop: input.playback?.loop ?? false, + controls: input.playback?.controls ?? true, + muted: input.playback?.muted ?? false, + autoplayInPreview: input.playback?.autoplayInPreview ?? false, + trimStartSeconds: input.playback?.trimStartSeconds ?? 0, + ...(input.playback?.trimEndSeconds !== undefined + ? { trimEndSeconds: input.playback.trimEndSeconds } + : {}), + ...(input.playback?.playAcrossSlides !== undefined + ? { playAcrossSlides: input.playback.playAcrossSlides } + : {}), + ...(input.playback?.startOnClick !== undefined + ? { startOnClick: input.playback.startOnClick } + : {}), + ...(input.playback?.volume !== undefined ? { volume: input.playback.volume } : {}), + }, + }; +} + +function resolvePageIndex(project: ProjectDocument, batch: SlideUpsertBatch) { + if (batch.slideId) { + const index = project.pages.findIndex((page) => page.id === batch.slideId); + if (index < 0) throw new Error(`Unknown slideId: ${batch.slideId}.`); + return { index, created: false }; + } + if (!Number.isInteger(batch.slideNumber) || (batch.slideNumber ?? 0) < 1) { + throw new Error('Provide a valid one-based slideNumber or slideId.'); + } + const index = (batch.slideNumber ?? 1) - 1; + if (index > project.pages.length) throw new Error('Slide numbers cannot contain gaps.'); + return { index, created: index === project.pages.length }; +} + +async function apply( + project: ProjectDocument, + batch: SlideUpsertBatch, + options: SlideUpsertOptions, +): Promise { + validate(project, batch); + const target = resolvePageIndex(project, batch); + const existingPage = project.pages[target.index]; + const slideId = existingPage?.id ?? options.createId('page'); + const previousElementIds = existingPage?.elementIds ?? []; + const deleteIds = new Set(batch.deleteElementIds ?? []); + if (batch.mode === 'replace') previousElementIds.forEach((elementId) => deleteIds.add(elementId)); + + const resolvedElements = await Promise.all( + batch.elements.map(async (input) => ({ input, ...(await createElement(input, options)) })), + ); + + const elements = { ...project.elements }; + deleteIds.forEach((elementId) => delete elements[elementId]); + const assets = { ...project.assets }; + resolvedElements.forEach(({ asset, element }) => { + elements[element.id] = element; + if (asset) assets[asset.id] = asset; + }); + + const retainedIds = previousElementIds.filter( + (elementId) => + !deleteIds.has(elementId) && !batch.elements.some((item) => item.elementId === elementId), + ); + const zIndexes = new Map(batch.elements.map((element) => [element.elementId, element.zIndex])); + const orderedElementIds = [ + ...retainedIds, + ...batch.elements.map((element) => element.elementId), + ].sort( + (first, second) => + (zIndexes.get(first) ?? previousElementIds.indexOf(first)) - + (zIndexes.get(second) ?? previousElementIds.indexOf(second)), + ); + const animations = batch.elements + .flatMap((element) => + (element.animations ?? []).map((animation) => ({ elementId: element.elementId, animation })), + ) + .sort((first, second) => first.animation.order - second.animation.order) + .map(({ elementId, animation }) => ({ + id: options.createId(`animation-${elementId}`), + elementId, + effect: animation.effect, + trigger: animation.trigger ?? 'on-click', + delayMs: animation.delayMs ?? 0, + ...(animation.kind ? { kind: animation.kind } : {}), + order: animation.order, + ...(animation.durationMs !== undefined ? { durationMs: animation.durationMs } : {}), + ...(animation.direction ? { direction: animation.direction } : {}), + })); + const retainedAnimations = (existingPage?.animationBuilds ?? []).filter( + (build) => + !deleteIds.has(build.elementId) && + !batch.elements.some((item) => item.elementId === build.elementId), + ); + const orderedAnimations = [...retainedAnimations, ...animations].sort( + (first, second) => (first.order ?? 0) - (second.order ?? 0), + ); + const timestamp = new Date().toISOString(); + const page = { + id: slideId, + name: batch.slide?.name ?? existingPage?.name ?? `Slide ${target.index + 1}`, + width: batch.slide?.width ?? existingPage?.width ?? 1920, + height: batch.slide?.height ?? existingPage?.height ?? 1080, + background: batch.slide?.background ?? + existingPage?.background ?? { type: 'color' as const, color: '#050D10' }, + elementIds: orderedElementIds, + ...(batch.slide?.speakerNotes !== undefined + ? { speakerNotes: batch.slide.speakerNotes } + : existingPage?.speakerNotes + ? { speakerNotes: existingPage.speakerNotes } + : {}), + ...(orderedAnimations.length > 0 ? { animationBuilds: orderedAnimations } : {}), + ...(existingPage?.semanticDescription + ? { semanticDescription: { ...existingPage.semanticDescription, stale: true } } + : {}), + visible: existingPage?.visible ?? true, + }; + const pages = [...project.pages]; + if (target.created) pages.push(page); + else pages[target.index] = page; + const nextProject = { ...project, pages, elements, assets, updatedAt: timestamp }; + + return { + requestId: batch.requestId, + slideId, + slideNumber: target.index + 1, + createdSlide: target.created, + createdElements: batch.elements.filter((element) => !project.elements[element.elementId]) + .length, + updatedElements: batch.elements.filter((element) => + Boolean(project.elements[element.elementId]), + ).length, + deletedElements: deleteIds.size, + elementCount: orderedElementIds.length, + project: nextProject, + }; +} + +function validate(project: ProjectDocument, batch: SlideUpsertBatch) { + if (!batch.requestId?.trim()) throw new Error('requestId is required for idempotent upserts.'); + if (!['merge', 'replace'].includes(batch.mode)) throw new Error('mode must be merge or replace.'); + if (Boolean(batch.slideId) === Boolean(batch.slideNumber)) { + throw new Error('Provide exactly one of slideId or slideNumber.'); + } + if (!Array.isArray(batch.elements)) throw new Error('elements must be an array.'); + if (batch.elements.length > 100) throw new Error('A batch can contain at most 100 elements.'); + if ((batch.deleteElementIds?.length ?? 0) > 100) { + throw new Error('A batch can delete at most 100 elements.'); + } + if (batch.slide?.width !== undefined) requireFinite(batch.slide.width, 'slide.width', 1); + if (batch.slide?.height !== undefined) requireFinite(batch.slide.height, 'slide.height', 1); + if ( + batch.slide?.background?.type === 'asset' && + !project.assets[batch.slide.background.assetId] + ) { + throw new Error(`Unknown background assetId: ${batch.slide.background.assetId}.`); + } + const elementIds = new Set(); + batch.elements.forEach((element) => { + validateElement(element); + if (elementIds.has(element.elementId)) + throw new Error(`Duplicate elementId: ${element.elementId}.`); + elementIds.add(element.elementId); + }); + const animationOrders = batch.elements.flatMap((element) => + (element.animations ?? []).map((animation) => animation.order), + ); + if (new Set(animationOrders).size !== animationOrders.length) { + throw new Error('Animation order values must be unique within a batch.'); + } + + const target = resolvePageIndex(project, batch); + const existingPage = project.pages[target.index]; + const slideId = existingPage?.id ?? 'new-slide'; + const foreignElementIds = new Set( + project.pages.filter((page) => page.id !== slideId).flatMap((page) => page.elementIds), + ); + for (const elementId of elementIds) { + if (foreignElementIds.has(elementId)) { + throw new Error(`elementId ${elementId} belongs to another slide.`); + } + } + + const previousElementIds = existingPage?.elementIds ?? []; + const deleteIds = new Set(batch.deleteElementIds ?? []); + if (deleteIds.size !== (batch.deleteElementIds?.length ?? 0)) { + throw new Error('deleteElementIds cannot contain duplicates.'); + } + for (const elementId of elementIds) { + if (deleteIds.has(elementId)) { + throw new Error(`Cannot upsert and delete ${elementId} in the same batch.`); + } + } + for (const elementId of deleteIds) { + if (!previousElementIds.includes(elementId)) { + throw new Error(`Cannot delete ${elementId}; it does not belong to the target slide.`); + } + } +} + +export const slideUpsertService = { apply, validate }; diff --git a/apps/editor/src/services/webmcp/slideUpsertInputSchema.ts b/apps/editor/src/services/webmcp/slideUpsertInputSchema.ts new file mode 100644 index 00000000..04ac9f5f --- /dev/null +++ b/apps/editor/src/services/webmcp/slideUpsertInputSchema.ts @@ -0,0 +1,276 @@ +const frameSchema = { + type: 'object', + additionalProperties: false, + required: ['x', 'y', 'width', 'height'], + properties: { + x: { type: 'number' }, + y: { type: 'number' }, + width: { type: 'number', minimum: 1 }, + height: { type: 'number', minimum: 1 }, + }, +}; + +const cropSchema = { + type: 'object', + additionalProperties: false, + required: ['x', 'y', 'width', 'height'], + properties: { + x: { type: 'number' }, + y: { type: 'number' }, + width: { type: 'number', minimum: 0 }, + height: { type: 'number', minimum: 0 }, + }, +}; + +const animationSchema = { + type: 'object', + additionalProperties: false, + required: ['effect', 'order'], + properties: { + effect: { + type: 'string', + enum: [ + 'blinds', + 'clothesline', + 'color-planes', + 'confetti', + 'cube', + 'doorway', + 'dissolve', + 'drop', + 'droplet', + 'fade', + 'fade-and-move', + 'fade-through-color', + 'fall', + 'flip', + 'flop', + 'grid', + 'iris', + 'keyboard-typing', + 'line-draw', + 'mosaic', + 'move-in', + 'page-flip', + 'pivot', + 'push', + 'radial-wipe', + 'reflection', + 'reveal', + 'revolving-door', + 'scale', + 'swap', + 'switch', + 'swoosh', + 'twirl', + 'twist', + 'wipe', + ], + }, + trigger: { type: 'string', enum: ['on-click', 'after-transition', 'after-previous'] }, + kind: { type: 'string', enum: ['build-in', 'build-out', 'emphasis'] }, + delayMs: { type: 'number', minimum: 0 }, + durationMs: { type: 'number', minimum: 0 }, + direction: { type: 'string', enum: ['up', 'right', 'down', 'left'] }, + order: { type: 'integer', minimum: 0 }, + }, +}; + +const commonProperties = { + elementId: { type: 'string', minLength: 1, maxLength: 500 }, + frame: frameSchema, + zIndex: { type: 'integer', minimum: 0 }, + rotation: { type: 'number' }, + opacity: { type: 'number', minimum: 0, maximum: 1 }, + visible: { type: 'boolean' }, + locked: { type: 'boolean' }, + animations: { type: 'array', maxItems: 50, items: animationSchema }, +}; + +const mediaContentSchema = { + type: 'object', + additionalProperties: false, + properties: { + assetId: { type: 'string', minLength: 1, maxLength: 500 }, + url: { type: 'string', minLength: 1, maxLength: 8000 }, + mediaRef: { type: 'string', minLength: 1, maxLength: 500 }, + }, + oneOf: [{ required: ['assetId'] }, { required: ['url'] }, { required: ['mediaRef'] }], +}; + +function elementSchema( + type: 'gif' | 'image' | 'shape' | 'text' | 'video', + properties: Record, + required: string[], +) { + return { + type: 'object', + additionalProperties: false, + required: ['elementId', 'type', 'frame', 'zIndex', ...required], + properties: { + ...commonProperties, + type: { const: type }, + ...properties, + }, + }; +} + +const backgroundSchema = { + oneOf: [ + { + type: 'object', + additionalProperties: false, + required: ['type', 'color'], + properties: { type: { const: 'color' }, color: { type: 'string' } }, + }, + { + type: 'object', + additionalProperties: false, + required: ['type', 'assetId', 'colorFallback'], + properties: { + type: { const: 'asset' }, + assetId: { type: 'string' }, + colorFallback: { type: 'string' }, + }, + }, + ], +}; + +export const slideUpsertInputSchema = { + type: 'object', + additionalProperties: false, + required: ['requestId', 'mode', 'elements'], + oneOf: [ + { required: ['slideId'], not: { required: ['slideNumber'] } }, + { required: ['slideNumber'], not: { required: ['slideId'] } }, + ], + properties: { + requestId: { type: 'string', minLength: 1, maxLength: 500 }, + slideId: { type: 'string', minLength: 1, maxLength: 500 }, + slideNumber: { type: 'integer', minimum: 1 }, + mode: { type: 'string', enum: ['merge', 'replace'] }, + slide: { + type: 'object', + additionalProperties: false, + properties: { + name: { type: 'string', maxLength: 500 }, + width: { type: 'number', minimum: 1, maximum: 10000 }, + height: { type: 'number', minimum: 1, maximum: 10000 }, + background: backgroundSchema, + speakerNotes: { type: 'string', maxLength: 100000 }, + }, + }, + elements: { + type: 'array', + maxItems: 100, + items: { + oneOf: [ + elementSchema( + 'text', + { + content: { + type: 'object', + additionalProperties: false, + required: ['text'], + properties: { text: { type: 'string', maxLength: 100000 } }, + }, + style: { + type: 'object', + additionalProperties: false, + required: ['fontFamily', 'fontSize', 'fontWeight', 'color'], + properties: { + fontFamily: { type: 'string', minLength: 1, maxLength: 500 }, + fontSize: { type: 'number', minimum: 1 }, + fontWeight: { type: 'number', minimum: 1 }, + color: { type: 'string' }, + align: { type: 'string', enum: ['left', 'center', 'right'] }, + verticalAlign: { type: 'string', enum: ['bottom', 'middle', 'top'] }, + lineHeight: { type: 'number', minimum: 0 }, + highlight: { type: 'string' }, + }, + }, + }, + ['content', 'style'], + ), + elementSchema( + 'image', + { + content: mediaContentSchema, + crop: cropSchema, + flipX: { type: 'boolean' }, + mask: { const: 'ellipse' }, + }, + ['content'], + ), + elementSchema( + 'gif', + { + content: mediaContentSchema, + playing: { type: 'boolean' }, + }, + ['content'], + ), + elementSchema( + 'video', + { + content: mediaContentSchema, + playback: { + type: 'object', + additionalProperties: false, + properties: { + loop: { type: 'boolean' }, + controls: { type: 'boolean' }, + muted: { type: 'boolean' }, + autoplayInPreview: { type: 'boolean' }, + trimStartSeconds: { type: 'number', minimum: 0 }, + trimEndSeconds: { type: 'number', minimum: 0 }, + playAcrossSlides: { type: 'boolean' }, + startOnClick: { type: 'boolean' }, + volume: { type: 'number', minimum: 0, maximum: 1 }, + }, + }, + }, + ['content'], + ), + elementSchema( + 'shape', + { + content: { + type: 'object', + additionalProperties: false, + required: ['shape'], + properties: { + shape: { + type: 'string', + enum: [ + 'arc', + 'arrow', + 'diamond', + 'ellipse', + 'line', + 'parallelogram', + 'pentagon', + 'rect', + 'rounded-rect', + 'triangle', + ], + }, + fill: { type: 'string' }, + stroke: { type: 'string' }, + strokeWidth: { type: 'number', minimum: 0 }, + }, + }, + }, + ['content'], + ), + ], + }, + }, + deleteElementIds: { + type: 'array', + maxItems: 100, + uniqueItems: true, + items: { type: 'string', minLength: 1, maxLength: 500 }, + }, + }, +}; diff --git a/apps/editor/src/services/webmcp/webMcpToolAdapter.ts b/apps/editor/src/services/webmcp/webMcpToolAdapter.ts index b91a2319..99a51d7f 100644 --- a/apps/editor/src/services/webmcp/webMcpToolAdapter.ts +++ b/apps/editor/src/services/webmcp/webMcpToolAdapter.ts @@ -1,19 +1,26 @@ -import type { - AutomationResult, - GenerateImageAutomationInput, - TranslateTextAutomationInput, -} from '../automation/editorAutomationController'; -import { editorAutomationController } from '../automation/editorAutomationController'; +import type { AuthoringResult } from '../automation/authoringAutomationController'; +import { authoringAutomationController } from '../automation/authoringAutomationController'; +import type { SlideUpsertBatch } from '../automation/slideUpsertService'; import { promptRecipes } from '../../ui/editor/prompting/promptRecipes'; +import { slideUpsertInputSchema } from './slideUpsertInputSchema'; type ToolInput = Record; -type EditorAutomationController = InstanceType; +type AuthoringAutomationController = InstanceType< + typeof authoringAutomationController.AuthoringAutomationController +>; + +export interface WebMcpToolAnnotations { + readOnlyHint?: boolean; + untrustedContentHint?: boolean; +} export interface WebMcpTool { + annotations?: WebMcpToolAnnotations; description: string; - execute(input: ToolInput): Promise> | AutomationResult; + execute(input: ToolInput): Promise> | AuthoringResult; inputSchema: Record; name: string; + title: string; } export interface WebMcpModelContext { @@ -25,18 +32,13 @@ export interface WebMcpDemoWindow extends Window { localStudioWebMcpTools?: WebMcpTool[]; } -type ControllerLike = Pick< - EditorAutomationController, - 'createProject' | 'generateSlides' | 'generateImage' | 'translateText' | 'getProjectSnapshot' ->; - -function promptExamplesList(examples: readonly string[]) { - return examples.map((example) => `- ${example}`).join('\n'); +function stringInput(input: ToolInput, key: string) { + return typeof input[key] === 'string' ? input[key] : ''; } -function stringInput(input: ToolInput, key: string) { - const value = input[key]; - return typeof value === 'string' ? value : ''; +function optionalStringInput(input: ToolInput, key: string) { + const value = stringInput(input, key).trim(); + return value || undefined; } function optionalNumberInput(input: ToolInput, key: string) { @@ -44,26 +46,22 @@ function optionalNumberInput(input: ToolInput, key: string) { return typeof value === 'number' && Number.isFinite(value) ? value : undefined; } -function imageInput(input: ToolInput): GenerateImageAutomationInput { - const height = optionalNumberInput(input, 'height'); - const seed = optionalNumberInput(input, 'seed'); - const steps = optionalNumberInput(input, 'steps'); - const width = optionalNumberInput(input, 'width'); - return { - prompt: stringInput(input, 'prompt'), - ...(height !== undefined ? { height } : {}), - ...(seed !== undefined ? { seed } : {}), - ...(steps !== undefined ? { steps } : {}), - ...(width !== undefined ? { width } : {}), - }; +function optionalBooleanInput(input: ToolInput, key: string) { + return typeof input[key] === 'boolean' ? input[key] : undefined; } -function translateInput(input: ToolInput): TranslateTextAutomationInput { - return { - scope: stringInput(input, 'scope'), - targetLanguage: stringInput(input, 'targetLanguage'), - ...(stringInput(input, 'pageId') ? { pageId: stringInput(input, 'pageId') } : {}), - }; +function optionalStringArrayInput(input: ToolInput, key: string) { + const value = input[key]; + return Array.isArray(value) + ? value.filter((item): item is string => typeof item === 'string') + : undefined; +} + +function optionalNumberArrayInput(input: ToolInput, key: string) { + const value = input[key]; + return Array.isArray(value) + ? value.filter((item): item is number => typeof item === 'number' && Number.isFinite(item)) + : undefined; } function isCleanupCallback(value: unknown): value is () => void { @@ -75,75 +73,379 @@ function isDuplicateToolNameError(error: unknown) { return error.name === 'InvalidStateError' && error.message.includes('Duplicate tool name'); } -function collectCleanup(cleanups: Array<() => void>, value: unknown) { - if (isCleanupCallback(value)) cleanups.push(value); -} +const emptyObjectSchema = { type: 'object', additionalProperties: false, properties: {} }; +const operationAnnotations = { readOnlyHint: false, untrustedContentHint: true }; +const readerAnnotations = { readOnlyHint: true, untrustedContentHint: true }; export class WebMcpToolAdapter { - constructor(private readonly controller: ControllerLike) {} + constructor(private readonly controller: AuthoringAutomationController) {} createTools(): WebMcpTool[] { return [ { - name: 'create_project', - description: 'Create a new blank LocalStudio.dev project in the active editor tab.', + name: 'create_presentation', + title: 'Create presentation', + description: + 'Create a blank LocalStudio presentation with an explicit name and optional canvas dimensions.', + annotations: operationAnnotations, + inputSchema: { + type: 'object', + additionalProperties: false, + properties: { + name: { type: 'string', maxLength: 200 }, + width: { type: 'number', minimum: 1, maximum: 10000 }, + height: { type: 'number', minimum: 1, maximum: 10000 }, + }, + }, + execute: (input) => + this.controller.createPresentation({ + ...(optionalStringInput(input, 'name') + ? { name: optionalStringInput(input, 'name') } + : {}), + ...(optionalNumberInput(input, 'width') !== undefined + ? { width: optionalNumberInput(input, 'width') } + : {}), + ...(optionalNumberInput(input, 'height') !== undefined + ? { height: optionalNumberInput(input, 'height') } + : {}), + }), + }, + { + name: 'get_presentation_state', + title: 'Inspect presentation state', + description: + 'Return bounded project and slide state. Detailed element data is limited to five slides per call.', + annotations: readerAnnotations, inputSchema: { type: 'object', - properties: { name: { type: 'string' } }, + additionalProperties: false, + properties: { + detail: { type: 'string', enum: ['summary', 'elements'] }, + slideNumbers: { type: 'array', items: { type: 'integer', minimum: 1 }, maxItems: 5 }, + cursor: { type: 'integer', minimum: 0 }, + elementCursor: { type: 'integer', minimum: 0 }, + elementLimit: { type: 'integer', minimum: 1, maximum: 50 }, + }, }, - execute: (input) => { - const name = stringInput(input, 'name'); - return this.controller.createProject(name ? { name } : {}); + execute: (input) => + this.controller.getPresentationState({ + detail: stringInput(input, 'detail') === 'elements' ? 'elements' : 'summary', + ...(optionalNumberArrayInput(input, 'slideNumbers') + ? { slideNumbers: optionalNumberArrayInput(input, 'slideNumbers') } + : {}), + ...(optionalNumberInput(input, 'cursor') !== undefined + ? { cursor: optionalNumberInput(input, 'cursor') } + : {}), + ...(optionalNumberInput(input, 'elementCursor') !== undefined + ? { elementCursor: optionalNumberInput(input, 'elementCursor') } + : {}), + ...(optionalNumberInput(input, 'elementLimit') !== undefined + ? { elementLimit: optionalNumberInput(input, 'elementLimit') } + : {}), + }), + }, + { + name: 'import_powerpoint_from_url', + title: 'Import PowerPoint from URL', + description: + 'Import a PPTX from an authorized HTTP(S), presigned object-storage, or localhost URL using the native PowerPoint workflow.', + annotations: operationAnnotations, + inputSchema: { + type: 'object', + additionalProperties: false, + required: ['url'], + properties: { + url: { type: 'string', minLength: 1, maxLength: 8000 }, + fileName: { type: 'string', minLength: 1, maxLength: 500 }, + }, }, + execute: (input) => + this.controller.importPowerPointFromUrl({ + url: stringInput(input, 'url'), + ...(optionalStringInput(input, 'fileName') + ? { fileName: optionalStringInput(input, 'fileName') } + : {}), + }), }, { - name: 'generate_slides', - description: `Generate slide content on the active page from a prompt. Good prompt examples:\n${promptExamplesList(promptRecipes.slidePromptExamples)}`, + name: 'translate_deck_and_notes', + title: 'Translate deck and notes', + description: + 'Translate all visible text, speaker notes, and existing semantic descriptions in the presentation.', + annotations: operationAnnotations, inputSchema: { type: 'object', - required: ['prompt'], - properties: { prompt: { type: 'string' } }, + additionalProperties: false, + required: ['targetLanguage'], + properties: { + targetLanguage: { type: 'string', minLength: 1, maxLength: 100 }, + sourceLanguage: { type: 'string', minLength: 1, maxLength: 100 }, + }, + }, + execute: (input) => + this.controller.translateDeckAndNotes({ + targetLanguage: stringInput(input, 'targetLanguage'), + ...(optionalStringInput(input, 'sourceLanguage') + ? { sourceLanguage: optionalStringInput(input, 'sourceLanguage') } + : {}), + }), + }, + { + name: 'generate_deck_detailed_description', + title: 'Generate detailed deck descriptions', + description: + 'Generate fresh hidden semantic descriptions from structured slide content for attendee AI grounding.', + annotations: operationAnnotations, + inputSchema: { + type: 'object', + additionalProperties: false, + properties: { + slideNumbers: { + type: 'array', + maxItems: 100, + uniqueItems: true, + items: { type: 'integer', minimum: 1 }, + }, + language: { type: 'string', minLength: 1, maxLength: 100 }, + force: { type: 'boolean' }, + }, }, - execute: (input) => this.controller.generateSlides({ prompt: stringInput(input, 'prompt') }), + execute: (input) => + this.controller.generateDeckDetailedDescription({ + ...(optionalNumberArrayInput(input, 'slideNumbers') + ? { slideNumbers: optionalNumberArrayInput(input, 'slideNumbers') } + : {}), + ...(optionalStringInput(input, 'language') + ? { language: optionalStringInput(input, 'language') } + : {}), + ...(optionalBooleanInput(input, 'force') !== undefined + ? { force: optionalBooleanInput(input, 'force') } + : {}), + }), + }, + { + name: 'list_authoring_catalog', + title: 'List authoring catalog', + description: + 'List usable fonts or animations compatible with a text, image, GIF, video, or shape element.', + annotations: readerAnnotations, + inputSchema: { + type: 'object', + additionalProperties: false, + required: ['kind'], + allOf: [ + { + if: { properties: { kind: { const: 'animations' } } }, + then: { required: ['elementType'] }, + }, + ], + properties: { + kind: { type: 'string', enum: ['fonts', 'animations'] }, + elementType: { type: 'string', enum: ['text', 'image', 'gif', 'video', 'shape'] }, + }, + }, + execute: (input) => + this.controller.listAuthoringCatalog({ + kind: stringInput(input, 'kind') as 'fonts' | 'animations', + ...(optionalStringInput(input, 'elementType') + ? { + elementType: optionalStringInput(input, 'elementType') as + | 'text' + | 'image' + | 'gif' + | 'video' + | 'shape', + } + : {}), + }), + }, + { + name: 'upsert_slide_content', + title: 'Upsert exact slide content', + description: + 'Atomically merge or replace exact slide primitives using stable IDs, frames, z-indexes, styles, media, and animations.', + annotations: operationAnnotations, + inputSchema: slideUpsertInputSchema, + execute: (input) => + this.controller.upsertSlideContent(input as unknown as SlideUpsertBatch), }, { name: 'generate_image', - description: `Generate an image for the active slide. If an image is selected, replace it; otherwise insert a fitted image. Good prompt examples:\n${promptExamplesList(promptRecipes.imagePromptExamples)}`, + title: 'Generate image asset', + description: `Generate an image asset without placing it. Use its assetId in upsert_slide_content. Example: ${promptRecipes.imagePromptExamples[0]}`, + annotations: operationAnnotations, inputSchema: { type: 'object', + additionalProperties: false, required: ['prompt'], properties: { - height: { type: 'number' }, - prompt: { type: 'string' }, - seed: { type: 'number' }, - steps: { type: 'number' }, - width: { type: 'number' }, + prompt: { type: 'string', minLength: 1, maxLength: 10000 }, + width: { type: 'integer', minimum: 64, maximum: 4096 }, + height: { type: 'integer', minimum: 64, maximum: 4096 }, + seed: { type: 'integer' }, + steps: { type: 'integer', minimum: 1, maximum: 100 }, + }, + }, + execute: (input) => + this.controller.generateImage({ + prompt: stringInput(input, 'prompt'), + ...(optionalNumberInput(input, 'width') !== undefined + ? { width: optionalNumberInput(input, 'width') } + : {}), + ...(optionalNumberInput(input, 'height') !== undefined + ? { height: optionalNumberInput(input, 'height') } + : {}), + ...(optionalNumberInput(input, 'seed') !== undefined + ? { seed: optionalNumberInput(input, 'seed') } + : {}), + ...(optionalNumberInput(input, 'steps') !== undefined + ? { steps: optionalNumberInput(input, 'steps') } + : {}), + }), + }, + { + name: 'get_slide_preview', + title: 'Focus slide preview', + description: + 'Select and fit a slide in the visible editor for browser-vision inspection and return its render hash.', + annotations: readerAnnotations, + inputSchema: { + type: 'object', + additionalProperties: false, + required: ['slideNumber'], + properties: { slideNumber: { type: 'integer', minimum: 1 } }, + }, + execute: (input) => + this.controller.getSlidePreview({ + slideNumber: optionalNumberInput(input, 'slideNumber') ?? 0, + }), + }, + { + name: 'get_ai_model_status', + title: 'Inspect AI model status', + description: + 'Report browser compatibility, selected providers, model readiness, sizes, progress, and errors.', + annotations: readerAnnotations, + inputSchema: emptyObjectSchema, + execute: () => this.controller.getAiModelStatus(), + }, + { + name: 'prepare_ai_models', + title: 'Prepare AI models', + description: + 'Download required AI models or explicit model IDs and expose progress through get_operation_status.', + annotations: operationAnnotations, + inputSchema: { + type: 'object', + additionalProperties: false, + properties: { + modelIds: { + type: 'array', + maxItems: 50, + uniqueItems: true, + items: { type: 'string', minLength: 1, maxLength: 500 }, + }, }, }, - execute: (input) => this.controller.generateImage(imageInput(input)), + execute: (input) => + this.controller.prepareAiModels({ + ...(optionalStringArrayInput(input, 'modelIds') + ? { modelIds: optionalStringArrayInput(input, 'modelIds') } + : {}), + }), }, { - name: 'translate_text', - description: 'Translate visible text in the active LocalStudio.dev project. Scope must be selection, slide, or deck.', + name: 'search_media', + title: 'Search stock media', + description: + 'Search configured Unsplash images or Giphy GIFs and return bounded media references with attribution.', + annotations: readerAnnotations, inputSchema: { type: 'object', - required: ['scope', 'targetLanguage'], + additionalProperties: false, + required: ['kind', 'term'], properties: { - pageId: { type: 'string' }, - scope: { type: 'string', enum: ['selection', 'slide', 'deck'] }, - targetLanguage: { type: 'string' }, + kind: { type: 'string', enum: ['image', 'gif'] }, + term: { type: 'string', minLength: 1, maxLength: 500 }, + limit: { type: 'integer', minimum: 1, maximum: 30 }, }, }, - execute: (input) => this.controller.translateText(translateInput(input)), + execute: (input) => + this.controller.searchMedia({ + kind: stringInput(input, 'kind') as 'image' | 'gif', + term: stringInput(input, 'term'), + ...(optionalNumberInput(input, 'limit') !== undefined + ? { limit: optionalNumberInput(input, 'limit') } + : {}), + }), }, { - name: 'get_project_snapshot', - description: 'Return a compact JSON snapshot of the active LocalStudio.dev project without large image payloads.', + name: 'export_presentation', + title: 'Export presentation', + description: + 'Export as PPTX, PDF, PNG archive, or JPEG archive using the visible editor render path.', + annotations: operationAnnotations, inputSchema: { type: 'object', - properties: {}, + additionalProperties: false, + required: ['format'], + properties: { + format: { type: 'string', enum: ['pptx', 'pdf', 'png', 'jpeg'] }, + slideRange: { type: 'string', enum: ['all', 'current'] }, + includeAnimationFrames: { type: 'boolean' }, + }, }, - execute: () => this.controller.getProjectSnapshot(), + execute: (input) => + this.controller.exportPresentation({ + format: stringInput(input, 'format') as 'pptx' | 'pdf' | 'png' | 'jpeg', + ...(optionalStringInput(input, 'slideRange') + ? { slideRange: optionalStringInput(input, 'slideRange') as 'all' | 'current' } + : {}), + ...(optionalBooleanInput(input, 'includeAnimationFrames') !== undefined + ? { includeAnimationFrames: optionalBooleanInput(input, 'includeAnimationFrames') } + : {}), + }), + }, + { + name: 'publish_presentation', + title: 'Publish presentation', + description: + 'Publish the exact current revision, fonts, descriptions, transcript, and authorized recording media.', + annotations: operationAnnotations, + inputSchema: { + type: 'object', + additionalProperties: false, + properties: { shareId: { type: 'string', minLength: 1, maxLength: 500 } }, + }, + execute: (input) => + this.controller.publishPresentation({ + ...(optionalStringInput(input, 'shareId') + ? { shareId: optionalStringInput(input, 'shareId') } + : {}), + }), + }, + { + name: 'get_operation_status', + title: 'Get authoring operation status', + description: + 'Read queued, running, completed, or failed progress and the typed final result.', + annotations: readerAnnotations, + inputSchema: { + type: 'object', + additionalProperties: false, + required: ['operationId'], + properties: { + operationId: { type: 'string', minLength: 1, maxLength: 500 }, + waitForChangeMs: { type: 'integer', minimum: 0, maximum: 5000 }, + }, + }, + execute: (input) => + this.controller.getOperationStatus({ + operationId: stringInput(input, 'operationId'), + ...(optionalNumberInput(input, 'waitForChangeMs') !== undefined + ? { waitForChangeMs: optionalNumberInput(input, 'waitForChangeMs') } + : {}), + }), }, ]; } @@ -153,22 +455,21 @@ export class WebMcpToolAdapter { const cleanups: Array<() => void> = []; if (modelContext.registerTools) { try { - collectCleanup(cleanups, modelContext.registerTools(tools)); + const cleanup = modelContext.registerTools(tools); + if (isCleanupCallback(cleanup)) cleanups.push(cleanup); } catch (error) { if (!isDuplicateToolNameError(error)) throw error; } } else { tools.forEach((tool) => { try { - collectCleanup(cleanups, modelContext.registerTool?.(tool)); + const cleanup = modelContext.registerTool?.(tool); + if (isCleanupCallback(cleanup)) cleanups.push(cleanup); } catch (error) { if (!isDuplicateToolNameError(error)) throw error; } }); } - - return () => { - cleanups.forEach((cleanup) => cleanup()); - }; + return () => cleanups.forEach((cleanup) => cleanup()); } } diff --git a/apps/editor/src/ui/editor/shell/EditorShell.tsx b/apps/editor/src/ui/editor/shell/EditorShell.tsx index 50ee5c91..72cf5ce1 100644 --- a/apps/editor/src/ui/editor/shell/EditorShell.tsx +++ b/apps/editor/src/ui/editor/shell/EditorShell.tsx @@ -7,8 +7,8 @@ import type { ProjectDocument, TranscriptRecording } from '../../../domain/docum import { pageVisibility } from '../../../domain/documents/pageVisibility'; import type { ShareMetadata, SharePublishProgress } from '../../../services/contracts/interfaces'; import { analyticsModelProperties } from '../../../services/analytics/analyticsModelProperties'; -import { editorAutomationController } from '../../../services/automation/editorAutomationController'; -import type { EditorAutomationDelegate } from '../../../services/automation/editorAutomationController'; +import { authoringAutomationController } from '../../../services/automation/authoringAutomationController'; +import { createAuthoringAutomationDelegate } from '../../../services/automation/createAuthoringAutomationDelegate'; import { imageGenerationModel } from '../../../services/image-generation/imageGenerationModel'; import { WebMcpToolAdapter, @@ -118,6 +118,7 @@ export function EditorShell({ services }: EditorShellProps) { function EditorDesktopShell({ services }: EditorShellProps) { const vm = useEditorViewModel(services); + const authoringVmRef = useRef(vm); const presenterTranscriptionLanguage = vm.translationLanguageOptions.find( (language) => language.code === vm.translationTargetLanguage, @@ -1210,6 +1211,8 @@ function EditorDesktopShell({ services }: EditorShellProps) { automationDelegateRef.current = vm.automation; }, [vm.automation]); + authoringVmRef.current = vm; + useEffect(() => { prepareProjectFontsForPublicShareRef.current = vm.prepareProjectFontsForPublicShare; }); @@ -1495,15 +1498,15 @@ function EditorDesktopShell({ services }: EditorShellProps) { useEffect(() => { if (!editorShellBrowserUtils.isWebMcpProtocolEnabled()) return undefined; - const delegate: EditorAutomationDelegate = { - createProject: (input) => automationDelegateRef.current.createProject(input), - generateSlides: (input) => automationDelegateRef.current.generateSlides(input), - generateImage: (input) => automationDelegateRef.current.generateImage(input), - translateText: (input) => automationDelegateRef.current.translateText(input), - getState: () => automationDelegateRef.current.getState(), - }; + const delegate = createAuthoringAutomationDelegate({ + fontImportService: services.fontImportService, + getProject: () => automationDelegateRef.current.getState().project, + replaceProject: (project) => authoringVmRef.current.replaceProjectForAutomation(project), + applyProject: (project, activePageId) => + authoringVmRef.current.applyProjectForAutomation(project, activePageId), + }); const adapter = new WebMcpToolAdapter( - new editorAutomationController.EditorAutomationController(delegate), + new authoringAutomationController.AuthoringAutomationController(delegate), ); const demoWindow = window as WebMcpDemoWindow; const modelContext = editorShellBrowserUtils.getWebMcpModelContext(); @@ -1514,7 +1517,7 @@ function EditorDesktopShell({ services }: EditorShellProps) { unregister?.(); delete demoWindow.localStudioWebMcpTools; }; - }, []); + }, [services.fontImportService]); useEffect( () => () => { diff --git a/apps/editor/src/ui/editor/state/useEditorViewModel.ts b/apps/editor/src/ui/editor/state/useEditorViewModel.ts index 4ad93a41..d0c36220 100644 --- a/apps/editor/src/ui/editor/state/useEditorViewModel.ts +++ b/apps/editor/src/ui/editor/state/useEditorViewModel.ts @@ -724,6 +724,13 @@ export function useEditorViewModel(services: AppServices) { setLastEditedAt(nextProject.updatedAt); } + function applyProjectForAutomation(nextProject: ProjectDocument, nextActivePageId?: string) { + commitProject(() => nextProject, { + ...(nextActivePageId ? { activePageId: nextActivePageId } : {}), + selectedElementIds: [], + }); + } + async function downloadRequiredModels() { setModelStates((currentStates) => currentStates.map((state) => @@ -3596,6 +3603,8 @@ export function useEditorViewModel(services: AppServices) { return { project: previewProject ?? project, automation, + applyProjectForAutomation, + replaceProjectForAutomation, activePageId, activePageFocusKey, zoomPercent, diff --git a/apps/editor/src/ui/webmcp/WebMcpShowcasePage.tsx b/apps/editor/src/ui/webmcp/WebMcpShowcasePage.tsx index fe8db747..8d004e70 100644 --- a/apps/editor/src/ui/webmcp/WebMcpShowcasePage.tsx +++ b/apps/editor/src/ui/webmcp/WebMcpShowcasePage.tsx @@ -1,7 +1,5 @@ -import { Bot, FileJson, ImagePlus, Languages, Play, Radar, SendHorizontal } from 'lucide-react'; +import { Bot, FileJson, Play, Radar, SendHorizontal } from 'lucide-react'; import { useMemo, useRef, useState } from 'react'; -import { promptRecipes } from '../editor/prompting/promptRecipes'; -import { TRANSLATION_LANGUAGE_OPTIONS } from '../editor/translation/translationLanguages'; interface WebMcpToolLike { call?: (input: Record) => unknown; @@ -24,34 +22,59 @@ interface DemoStep { const demoSteps: DemoStep[] = [ { - label: 'Create project', - toolName: 'create_project', + label: 'Create presentation', + toolName: 'create_presentation', input: { name: 'WebMCP Demo Deck' }, }, { - label: 'Generate slide', - toolName: 'generate_slides', - input: { prompt: promptRecipes.slidePromptExamples[1] }, - }, - { - label: 'Generate image', - toolName: 'generate_image', + label: 'Upsert slide', + toolName: 'upsert_slide_content', input: { - prompt: promptRecipes.imagePromptExamples[1], - width: 512, - height: 512, - steps: 4, + requestId: 'webmcp-showcase-slide-1', + slideNumber: 1, + mode: 'replace', + slide: { + name: 'Agent-native presentations', + background: { type: 'color', color: '#050D10' }, + }, + elements: [ + { + elementId: 'showcase-title', + type: 'text', + frame: { x: 180, y: 260, width: 1560, height: 220 }, + zIndex: 1, + content: { text: 'Presentations become agent-native' }, + style: { + fontFamily: 'Orbitron', + fontSize: 88, + fontWeight: 800, + color: '#37FD76', + align: 'center', + }, + }, + { + elementId: 'showcase-body', + type: 'text', + frame: { x: 360, y: 560, width: 1200, height: 120 }, + zIndex: 2, + content: { + text: 'Create, inspect, localize, export, and publish through browser-native tools.', + }, + style: { + fontFamily: 'Open Sans', + fontSize: 42, + fontWeight: 600, + color: '#FFFFFF', + align: 'center', + }, + }, + ], }, }, { - label: 'Translate deck', - toolName: 'translate_text', - input: { scope: 'deck', targetLanguage: 'pt' }, - }, - { - label: 'Read snapshot', - toolName: 'get_project_snapshot', - input: {}, + label: 'Read presentation state', + toolName: 'get_presentation_state', + input: { detail: 'elements', slideNumbers: [1] }, }, ]; @@ -61,10 +84,13 @@ function getBrowserModelContext() { } function isWebMcpToolLikeArray(value: unknown): value is WebMcpToolLike[] { - return Array.isArray(value) && value.every((item) => { - if (!item || typeof item !== 'object') return false; - return typeof (item as { name?: unknown }).name === 'string'; - }); + return ( + Array.isArray(value) && + value.every((item) => { + if (!item || typeof item !== 'object') return false; + return typeof (item as { name?: unknown }).name === 'string'; + }) + ); } function getLocalDemoTools(iframe: HTMLIFrameElement) { @@ -79,7 +105,8 @@ function callTool( input: Record, modelContext = getBrowserModelContext(), ) { - if (modelContext?.executeTool) return Promise.resolve(modelContext.executeTool(tool, JSON.stringify(input))); + if (modelContext?.executeTool) + return Promise.resolve(modelContext.executeTool(tool, JSON.stringify(input))); const callable = tool.call ?? tool.execute ?? tool.invoke; if (!callable) throw new Error(`${tool.name} is not callable in this WebMCP runtime.`); return Promise.resolve(callable(input)); @@ -90,20 +117,20 @@ function formatPayload(value: unknown) { } function getDefaultCommandValue(step: DemoStep) { - const primaryValue = step.input.name ?? step.input.prompt ?? step.input.targetLanguage; + const primaryValue = step.input.name; return typeof primaryValue === 'string' ? primaryValue : formatPayload(step.input); } function getCommandInput(step: DemoStep, value: string) { - if (step.toolName === 'create_project') return { name: value }; - if (step.toolName === 'generate_slides') return { prompt: value }; - if (step.toolName === 'generate_image') return { ...step.input, prompt: value }; - if (step.toolName === 'translate_text') return { ...step.input, targetLanguage: value }; + if (step.toolName === 'create_presentation') return { name: value }; + if (step.toolName === 'upsert_slide_content') { + return JSON.parse(value) as Record; + } return step.input; } function hasCommandInput(step: DemoStep) { - return step.toolName !== 'get_project_snapshot'; + return step.toolName !== 'get_presentation_state'; } export function WebMcpShowcasePage() { @@ -154,7 +181,9 @@ export function WebMcpShowcasePage() { ? await modelContext.getTools({ fromOrigins: [iframeOrigin] }) : fallbackTools; if (!discoveredTools) { - setStatus('No WebMCP runtime or same-origin demo tools found. Wait for the editor frame, then try again.'); + setStatus( + 'No WebMCP runtime or same-origin demo tools found. Wait for the editor frame, then try again.', + ); setTools([]); return; } @@ -164,7 +193,11 @@ export function WebMcpShowcasePage() { ? `Discovered ${discoveredTools.length} tools through WebMCP.` : `Discovered ${discoveredTools.length} tools through the local demo bridge.`, ); - setLastResult(formatPayload(discoveredTools.map((tool) => ({ name: tool.name, description: tool.description })))); + setLastResult( + formatPayload( + discoveredTools.map((tool) => ({ name: tool.name, description: tool.description })), + ), + ); } async function runStep(step: DemoStep, input = step.input) { @@ -200,8 +233,8 @@ export function WebMcpShowcasePage() {

Browser agent surface

WebMCP showcase

- A host page discovers semantic tools from the editor iframe and calls the same automation layer - used by the LocalStudio interface. + A host page discovers semantic tools from the editor iframe and calls the same + automation layer used by the LocalStudio interface.

@@ -251,7 +284,9 @@ export function WebMcpShowcasePage() { 'webmcp-step-button', activeStepName === step.toolName ? 'webmcp-step-button-active' : '', focusedStepName === step.toolName ? 'webmcp-step-button-focused' : '', - ].filter(Boolean).join(' ')} + ] + .filter(Boolean) + .join(' ')} disabled={isRunning} type="button" onClick={() => { @@ -259,10 +294,8 @@ export function WebMcpShowcasePage() { }} > {index + 1} - {step.toolName === 'generate_image' ? : null} - {step.toolName === 'translate_text' ? : null} - {step.toolName === 'get_project_snapshot' ? : null} - {step.toolName === 'generate_slides' || step.toolName === 'create_project' ? : null} + {step.toolName === 'get_presentation_state' ? : null} + {step.toolName !== 'get_presentation_state' ? : null} {step.label} {activeStepName === step.toolName && hasCommandInput(step) ? ( @@ -273,35 +306,16 @@ export function WebMcpShowcasePage() { void runStep(step, getCommandInput(step, commandValues[step.toolName] ?? '')); }} > - {step.toolName === 'translate_text' ? ( - - ) : ( - { - setCommandValues((current) => ({ - ...current, - [step.toolName]: event.target.value, - })); - }} - /> - )} + { + setCommandValues((current) => ({ + ...current, + [step.toolName]: event.target.value, + })); + }} + /> diff --git a/apps/editor/tests/unit/app/App.test.tsx b/apps/editor/tests/unit/app/App.test.tsx index a17ba92d..71a77974 100644 --- a/apps/editor/tests/unit/app/App.test.tsx +++ b/apps/editor/tests/unit/app/App.test.tsx @@ -1,8 +1,7 @@ -import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; import { describe, expect, it, vi } from 'vitest'; import { App } from '../../../src/App'; import { sampleProject } from '../../../src/domain/projects/sampleProject'; -import { TRANSLATION_LANGUAGE_OPTIONS } from '../../../src/ui/editor/translation/translationLanguages'; const originalMatchMedia = window.matchMedia; @@ -159,11 +158,17 @@ describe('App', () => { ); }), ); - window.history.replaceState({}, '', `/editor/s/${shareId}?src=${encodeURIComponent(sourceUrl)}`); + window.history.replaceState( + {}, + '', + `/editor/s/${shareId}?src=${encodeURIComponent(sourceUrl)}`, + ); render(); - expect(await screen.findByLabelText('Public presentation')).toHaveClass('public-deck-viewer-present'); + expect(await screen.findByLabelText('Public presentation')).toHaveClass( + 'public-deck-viewer-present', + ); expect(screen.getByText('1 / 1')).toBeInTheDocument(); }); @@ -187,11 +192,17 @@ describe('App', () => { ); }), ); - window.history.replaceState({}, '', `/editor/?share=${shareId}&src=${encodeURIComponent(sourceUrl)}`); + window.history.replaceState( + {}, + '', + `/editor/?share=${shareId}&src=${encodeURIComponent(sourceUrl)}`, + ); render(); - expect(await screen.findByLabelText('Public presentation')).toHaveClass('public-deck-viewer-present'); + expect(await screen.findByLabelText('Public presentation')).toHaveClass( + 'public-deck-viewer-present', + ); expect(screen.getByText('1 / 1')).toBeInTheDocument(); }); @@ -215,7 +226,11 @@ describe('App', () => { ); }), ); - window.history.replaceState({}, '', `/editor/embed/${shareId}?src=${encodeURIComponent(sourceUrl)}`); + window.history.replaceState( + {}, + '', + `/editor/embed/${shareId}?src=${encodeURIComponent(sourceUrl)}`, + ); render(); @@ -243,7 +258,11 @@ describe('App', () => { ); }), ); - window.history.replaceState({}, '', `/editor/?embed=${shareId}&src=${encodeURIComponent(sourceUrl)}`); + window.history.replaceState( + {}, + '', + `/editor/?embed=${shareId}&src=${encodeURIComponent(sourceUrl)}`, + ); render(); @@ -263,79 +282,86 @@ describe('App', () => { render(); - fireEvent.click(screen.getByRole('button', { name: 'Create project' })); + fireEvent.click(screen.getByRole('button', { name: 'Create presentation' })); - expect(screen.getByLabelText('Create project command input')).toHaveValue('WebMCP Demo Deck'); - expect(screen.getByRole('button', { name: 'Send Create project' })).toBeInTheDocument(); + expect(screen.getByLabelText('Create presentation command input')).toHaveValue( + 'WebMCP Demo Deck', + ); + expect(screen.getByRole('button', { name: 'Send Create presentation' })).toBeInTheDocument(); }); - it('shows the AI tools translation options for the WebMCP translate step', () => { + it('shows the complete JSON batch for the WebMCP upsert step', () => { window.history.replaceState({}, '', '/webmcp'); render(); - fireEvent.click(screen.getByRole('button', { name: 'Translate deck' })); + fireEvent.click(screen.getByRole('button', { name: 'Upsert slide' })); - const languageSelect = screen.getByLabelText('Translate deck command input'); - const options = within(languageSelect).getAllByRole('option'); - expect(options).toHaveLength(TRANSLATION_LANGUAGE_OPTIONS.length); - expect(options.map((option) => option.value)).toEqual( - TRANSLATION_LANGUAGE_OPTIONS.map((language) => language.code), - ); - expect(screen.getByRole('option', { name: 'Hebrew (iw) 🇮🇱' })).toBeInTheDocument(); - expect(screen.getByRole('option', { name: 'Chinese (Traditional) (zh-Hant) 🇹🇼' })).toBeInTheDocument(); + const batchInput = screen.getByLabelText('Upsert slide command input'); + expect(batchInput.value).toContain('"requestId"'); + expect(batchInput.value).toContain('"elements"'); }); it('runs the WebMCP snapshot step without showing a command input', async () => { - const executeSnapshot = vi.fn().mockResolvedValue({ project: { name: 'Demo' } }); + const executeState = vi.fn().mockResolvedValue({ projectId: 'project-1', name: 'Demo' }); window.history.replaceState({}, '', '/webmcp'); Object.defineProperty(document, 'modelContext', { configurable: true, value: { - getTools: vi.fn().mockResolvedValue([ - { name: 'get_project_snapshot', description: 'Read snapshot', execute: executeSnapshot }, - ]), + getTools: vi + .fn() + .mockResolvedValue([ + { name: 'get_presentation_state', description: 'Read state', execute: executeState }, + ]), }, }); render(); fireEvent.click(screen.getByRole('button', { name: 'Discover tools' })); - fireEvent.click(await screen.findByRole('button', { name: 'get_project_snapshot' })); - fireEvent.click(screen.getByRole('button', { name: 'Read snapshot' })); + fireEvent.click(await screen.findByRole('button', { name: 'get_presentation_state' })); + fireEvent.click(screen.getByRole('button', { name: 'Read presentation state' })); await waitFor(() => { - expect(executeSnapshot).toHaveBeenCalledWith({}); + expect(executeState).toHaveBeenCalledWith({ detail: 'elements', slideNumbers: [1] }); }); - expect(screen.queryByLabelText('Read snapshot command input')).not.toBeInTheDocument(); - expect(screen.getByText('Read snapshot completed.')).toBeInTheDocument(); + expect( + screen.queryByLabelText('Read presentation state command input'), + ).not.toBeInTheDocument(); + expect(screen.getByText('Read presentation state completed.')).toBeInTheDocument(); }); it('runs WebMCP descriptor tools through the browser runtime executor', async () => { - const createProjectTool = { name: 'create_project', description: 'Create project' }; + const createPresentationTool = { + name: 'create_presentation', + description: 'Create presentation', + }; const executeTool = vi.fn().mockResolvedValue({ ok: true, data: { name: 'Runtime Deck' } }); window.history.replaceState({}, '', '/webmcp'); Object.defineProperty(document, 'modelContext', { configurable: true, value: { executeTool, - getTools: vi.fn().mockResolvedValue([createProjectTool]), + getTools: vi.fn().mockResolvedValue([createPresentationTool]), }, }); render(); fireEvent.click(screen.getByRole('button', { name: 'Discover tools' })); - fireEvent.click(await screen.findByRole('button', { name: 'Create project' })); - fireEvent.change(screen.getByLabelText('Create project command input'), { + fireEvent.click(await screen.findByRole('button', { name: 'Create presentation' })); + fireEvent.change(screen.getByLabelText('Create presentation command input'), { target: { value: 'Runtime Deck' }, }); - fireEvent.click(screen.getByRole('button', { name: 'Send Create project' })); + fireEvent.click(screen.getByRole('button', { name: 'Send Create presentation' })); await waitFor(() => { - expect(executeTool).toHaveBeenCalledWith(createProjectTool, JSON.stringify({ name: 'Runtime Deck' })); + expect(executeTool).toHaveBeenCalledWith( + createPresentationTool, + JSON.stringify({ name: 'Runtime Deck' }), + ); }); - expect(screen.getByText('Create project completed.')).toBeInTheDocument(); + expect(screen.getByText('Create presentation completed.')).toBeInTheDocument(); }); it('focuses the matching workflow step when a discovered tool is selected', async () => { @@ -344,8 +370,8 @@ describe('App', () => { configurable: true, value: { getTools: vi.fn().mockResolvedValue([ - { name: 'create_project', description: 'Create project', execute: vi.fn() }, - { name: 'generate_slides', description: 'Generate slides', execute: vi.fn() }, + { name: 'create_presentation', description: 'Create presentation', execute: vi.fn() }, + { name: 'upsert_slide_content', description: 'Upsert slide', execute: vi.fn() }, ]), }, }); @@ -353,9 +379,9 @@ describe('App', () => { render(); fireEvent.click(screen.getByRole('button', { name: 'Discover tools' })); - fireEvent.click(await screen.findByRole('button', { name: 'generate_slides' })); + fireEvent.click(await screen.findByRole('button', { name: 'upsert_slide_content' })); - const stepButton = screen.getByRole('button', { name: 'Generate slide' }); + const stepButton = screen.getByRole('button', { name: 'Upsert slide' }); expect(stepButton).toHaveFocus(); expect(stepButton).toHaveClass('webmcp-step-button-focused'); }); diff --git a/apps/editor/tests/unit/services/authoringAutomationController.test.ts b/apps/editor/tests/unit/services/authoringAutomationController.test.ts new file mode 100644 index 00000000..76a937da --- /dev/null +++ b/apps/editor/tests/unit/services/authoringAutomationController.test.ts @@ -0,0 +1,171 @@ +import { authoringAutomationController } from '../../../src/services/automation/authoringAutomationController'; +import type { AuthoringAutomationDelegate } from '../../../src/services/automation/authoringAutomationController'; +import type { SlideUpsertBatch } from '../../../src/services/automation/slideUpsertService'; + +function createDelegate( + overrides: Partial = {}, +): AuthoringAutomationDelegate { + return { + createPresentation: () => ({}), + getPresentationState: () => ({}), + upsertSlideContent: () => Promise.reject(new Error('unused')), + ...overrides, + }; +} + +describe('authoringAutomationController', () => { + it('reports asynchronous operation progress and completion', async () => { + const controller = new authoringAutomationController.AuthoringAutomationController( + createDelegate({ + importPowerPointFromUrl: (_input, report) => { + report({ stage: 'importing-powerpoint', progress: 60, current: 2, total: 3 }); + return Promise.resolve({ pageCount: 3 }); + }, + }), + ); + + const started = controller.importPowerPointFromUrl({ url: 'https://example.test/deck.pptx' }); + expect(started).toMatchObject({ ok: true, data: { status: 'queued' } }); + const operationId = started.ok ? started.data.operationId : ''; + await new Promise((resolve) => setTimeout(resolve, 0)); + + await expect(controller.getOperationStatus({ operationId })).resolves.toMatchObject({ + ok: true, + data: { + state: 'completed', + progress: 100, + percentage: 100, + revision: 3, + result: { pageCount: 3 }, + }, + }); + }); + + it('returns the original result for an idempotent upsert replay', async () => { + const upsertSlideContent = vi.fn((input: SlideUpsertBatch) => + Promise.resolve({ + requestId: input.requestId, + slideId: 'page-1', + slideNumber: 1, + createdSlide: false, + createdElements: 1, + updatedElements: 0, + deletedElements: 0, + elementCount: 1, + project: {} as never, + }), + ); + const controller = new authoringAutomationController.AuthoringAutomationController( + createDelegate({ upsertSlideContent }), + ); + const input = { + requestId: 'retry-safe', + slideNumber: 1, + mode: 'merge' as const, + elements: [], + }; + + await expect( + controller.upsertSlideContent({ + elements: input.elements, + mode: input.mode, + slideNumber: input.slideNumber, + requestId: input.requestId, + }), + ).resolves.toMatchObject({ + ok: true, + data: { idempotentReplay: false }, + }); + await expect(controller.upsertSlideContent(input)).resolves.toMatchObject({ + ok: true, + data: { idempotentReplay: true }, + }); + expect(upsertSlideContent).toHaveBeenCalledTimes(1); + }); + + it('coalesces concurrent retries for the same request ID', async () => { + const upsertSlideContent = vi.fn((input: SlideUpsertBatch) => + Promise.resolve({ + requestId: input.requestId, + slideId: 'page-1', + slideNumber: 1, + createdSlide: false, + createdElements: 0, + updatedElements: 0, + deletedElements: 0, + elementCount: 0, + project: {} as never, + }), + ); + const controller = new authoringAutomationController.AuthoringAutomationController( + createDelegate({ upsertSlideContent }), + ); + const input: SlideUpsertBatch = { + requestId: 'concurrent', + slideNumber: 1, + mode: 'merge', + elements: [], + }; + + const [first, retry] = await Promise.all([ + controller.upsertSlideContent(input), + controller.upsertSlideContent(input), + ]); + + expect(first).toMatchObject({ ok: true, data: { idempotentReplay: false } }); + expect(retry).toMatchObject({ ok: true, data: { idempotentReplay: true } }); + expect(upsertSlideContent).toHaveBeenCalledTimes(1); + }); + + it('rejects a reused request ID when the batch body changes', async () => { + const upsertSlideContent = vi.fn((input: SlideUpsertBatch) => + Promise.resolve({ + requestId: input.requestId, + slideId: 'page-1', + slideNumber: 1, + createdSlide: false, + createdElements: 0, + updatedElements: 0, + deletedElements: 0, + elementCount: 0, + project: {} as never, + }), + ); + const controller = new authoringAutomationController.AuthoringAutomationController( + createDelegate({ upsertSlideContent }), + ); + + await controller.upsertSlideContent({ + requestId: 'conflict', + slideNumber: 1, + mode: 'merge', + elements: [], + }); + await expect( + controller.upsertSlideContent({ + requestId: 'conflict', + slideNumber: 1, + mode: 'replace', + elements: [], + }), + ).resolves.toEqual({ + ok: false, + errorCode: 'request_id_conflict', + message: 'requestId conflict was already used with a different batch.', + }); + expect(upsertSlideContent).toHaveBeenCalledTimes(1); + }); + + it('returns an explicit pending result for reserved catalog capabilities', () => { + const controller = new authoringAutomationController.AuthoringAutomationController( + createDelegate(), + ); + + expect(controller.publishPresentation({})).toEqual({ + ok: false, + errorCode: 'capability_pending', + message: + 'publish_presentation is reserved in the authoring catalog and will be implemented in #177.', + }); + }); +}); diff --git a/apps/editor/tests/unit/services/createAuthoringAutomationDelegate.test.ts b/apps/editor/tests/unit/services/createAuthoringAutomationDelegate.test.ts new file mode 100644 index 00000000..f1ebddf0 --- /dev/null +++ b/apps/editor/tests/unit/services/createAuthoringAutomationDelegate.test.ts @@ -0,0 +1,158 @@ +import { sampleProject } from '../../../src/domain/projects/sampleProject'; +import { createAuthoringAutomationDelegate } from '../../../src/services/automation/createAuthoringAutomationDelegate'; +import type { + FontImportRequest, + FontImportService, +} from '../../../src/services/contracts/interfaces'; + +function createHarness() { + let project = sampleProject.createBlankProject(); + const resolveAndDownloadFonts = vi.fn((requests: FontImportRequest[]) => + Promise.resolve({ + fonts: Object.fromEntries( + requests.map((request) => [ + request.family, + { + id: `font-${request.family}`, + family: request.family, + source: 'google-fonts' as const, + requestedFamily: request.family, + fontStyle: request.fontStyle, + fontWeight: request.fontWeight, + mimeType: 'font/woff2' as const, + fileName: `${request.family}.woff2`, + storage: 'remote' as const, + sourceUrl: `https://fonts.example/${request.family}.woff2`, + }, + ]), + ), + resolutions: requests.map((request) => ({ + requestedFamily: request.family, + family: request.family, + fontStyle: request.fontStyle, + fontWeight: request.fontWeight, + status: 'downloaded-exact' as const, + })), + warnings: [], + }), + ); + const fontImportService: FontImportService = { + listDownloadableFonts: vi.fn(() => []), + resolveAndDownloadFonts, + loadProjectFonts: vi.fn(() => Promise.resolve()), + }; + const delegate = createAuthoringAutomationDelegate({ + fontImportService, + getProject: () => project, + replaceProject: (nextProject) => { + project = nextProject; + }, + applyProject: (nextProject) => { + project = nextProject; + }, + }); + return { delegate, resolveAndDownloadFonts, getProject: () => project }; +} + +describe('createAuthoringAutomationDelegate', () => { + it('creates a named 1920x1080 presentation and returns bounded state', async () => { + const harness = createHarness(); + + expect(await harness.delegate.createPresentation({ name: 'Agent Deck' })).toMatchObject({ + name: 'Agent Deck', + width: 1920, + height: 1080, + slideId: 'page-1', + }); + expect(await harness.delegate.getPresentationState({ detail: 'summary' })).toMatchObject({ + name: 'Agent Deck', + pageCount: 1, + slides: [{ slideNumber: 1, descriptionFreshness: 'missing' }], + }); + expect(() => harness.delegate.createPresentation({ width: 0 })).toThrow( + 'dimensions must be positive', + ); + }); + + it('paginates detailed elements without returning an unbounded slide payload', async () => { + const harness = createHarness(); + const elements = ['one', 'two', 'three'].map((elementId, index) => ({ + elementId, + type: 'text' as const, + frame: { x: index * 100, y: 0, width: 90, height: 50 }, + zIndex: index, + content: { text: elementId }, + style: { fontFamily: 'Arial', fontSize: 20, fontWeight: 400, color: '#000000' }, + })); + await harness.delegate.upsertSlideContent({ + requestId: 'state-pagination', + slideNumber: 1, + mode: 'replace', + elements, + }); + + expect( + await harness.delegate.getPresentationState({ + detail: 'elements', + slideNumbers: [1], + elementLimit: 2, + }), + ).toMatchObject({ + slides: [{ elements: [{ id: 'one' }, { id: 'two' }], nextElementCursor: 2 }], + }); + }); + + it('downloads an available referenced font before applying the exact text element', async () => { + const harness = createHarness(); + + await harness.delegate.upsertSlideContent({ + requestId: 'font-upsert', + slideNumber: 1, + mode: 'replace', + elements: [ + { + elementId: 'title', + type: 'text', + frame: { x: 10, y: 20, width: 500, height: 100 }, + zIndex: 1, + content: { text: 'Exact slide' }, + style: { fontFamily: 'Roboto Slab', fontSize: 48, fontWeight: 700, color: '#123456' }, + }, + ], + }); + + expect(harness.resolveAndDownloadFonts).toHaveBeenCalledWith([ + { family: 'Roboto Slab', fontStyle: 'normal', fontWeight: 700 }, + ]); + expect(harness.getProject().elements.title).toMatchObject({ + text: 'Exact slide', + x: 10, + y: 20, + fontFamily: 'Roboto Slab', + fill: '#123456', + }); + }); + + it('rejects unsafe media URLs without changing the project', async () => { + const harness = createHarness(); + const original = harness.getProject(); + + await expect( + harness.delegate.upsertSlideContent({ + requestId: 'unsafe-media', + slideNumber: 1, + mode: 'replace', + elements: [ + { + elementId: 'image', + type: 'image', + frame: { x: 0, y: 0, width: 100, height: 100 }, + zIndex: 0, + content: { url: 'file:///tmp/private.png' }, + }, + ], + }), + ).rejects.toThrow('Only HTTP and HTTPS'); + expect(harness.getProject()).toBe(original); + }); +}); diff --git a/apps/editor/tests/unit/services/slideUpsertService.test.ts b/apps/editor/tests/unit/services/slideUpsertService.test.ts new file mode 100644 index 00000000..eb12b52f --- /dev/null +++ b/apps/editor/tests/unit/services/slideUpsertService.test.ts @@ -0,0 +1,151 @@ +import { sampleProject } from '../../../src/domain/projects/sampleProject'; +import { slideUpsertService } from '../../../src/services/automation/slideUpsertService'; + +const textElement = { + elementId: 'title', + type: 'text' as const, + frame: { x: 100, y: 100, width: 900, height: 180 }, + zIndex: 2, + content: { text: 'Agent-native slides' }, + style: { + fontFamily: 'Orbitron', + fontSize: 72, + fontWeight: 800, + color: '#37FD76', + align: 'center' as const, + }, + animations: [{ effect: 'fade' as const, order: 1, durationMs: 500 }], +}; + +const options = { + createId: (prefix: string) => `${prefix}-test`, + resolveMedia: () => Promise.reject(new Error('Unexpected media resolution.')), +}; + +describe('slideUpsertService', () => { + it('replaces a slide atomically and preserves exact primitive values', async () => { + const project = sampleProject.createSampleProject(); + const result = await slideUpsertService.apply( + project, + { + requestId: 'replace-slide-1', + slideNumber: 1, + mode: 'replace', + slide: { name: 'WebMCP', background: { type: 'color', color: '#000000' } }, + elements: [textElement], + }, + options, + ); + + expect(result.project.pages[0]).toMatchObject({ + name: 'WebMCP', + elementIds: ['title'], + background: { type: 'color', color: '#000000' }, + }); + expect(result.project.elements.title).toMatchObject({ + id: 'title', + text: 'Agent-native slides', + x: 100, + y: 100, + width: 900, + height: 180, + fontFamily: 'Orbitron', + }); + expect(result.project.pages[0]?.animationBuilds).toHaveLength(1); + expect(result.deletedElements).toBe(3); + }); + + it('merges batches, sorts z-indexes, and performs explicit deletion', async () => { + const project = sampleProject.createBlankProject(); + const first = await slideUpsertService.apply( + project, + { + requestId: 'chunk-1', + slideNumber: 1, + mode: 'merge', + elements: [textElement], + }, + options, + ); + const second = await slideUpsertService.apply( + first.project, + { + requestId: 'chunk-2', + slideNumber: 1, + mode: 'merge', + elements: [ + { + ...textElement, + elementId: 'subtitle', + zIndex: 1, + content: { text: 'Bounded second batch' }, + animations: [], + }, + ], + deleteElementIds: ['title'], + }, + options, + ); + + expect(second.project.pages[0]?.elementIds).toEqual(['subtitle']); + expect(second.project.elements.title).toBeUndefined(); + expect(second.deletedElements).toBe(1); + }); + + it('rejects the complete batch before mutation when IDs collide across slides', async () => { + const project = sampleProject.createBlankProject(); + const withSecondSlide = await slideUpsertService.apply( + project, + { + requestId: 'new-slide', + slideNumber: 2, + mode: 'replace', + elements: [textElement], + }, + options, + ); + + await expect( + slideUpsertService.apply( + withSecondSlide.project, + { + requestId: 'collision', + slideNumber: 1, + mode: 'merge', + elements: [textElement], + }, + options, + ), + ).rejects.toThrow('belongs to another slide'); + expect(withSecondSlide.project.pages[0]?.elementIds).toEqual([]); + }); + + it('validates explicit deletion before resolving any media', async () => { + const project = sampleProject.createBlankProject(); + const resolveMedia = vi.fn(() => Promise.reject(new Error('must not run'))); + + await expect( + slideUpsertService.apply( + project, + { + requestId: 'atomic-validation', + slideNumber: 1, + mode: 'merge', + deleteElementIds: ['missing'], + elements: [ + { + elementId: 'image', + type: 'image', + frame: { x: 0, y: 0, width: 100, height: 100 }, + zIndex: 0, + content: { url: 'https://example.test/image.png' }, + }, + ], + }, + { ...options, resolveMedia }, + ), + ).rejects.toThrow('does not belong'); + expect(resolveMedia).not.toHaveBeenCalled(); + expect(project.pages[0]?.elementIds).toEqual([]); + }); +}); diff --git a/apps/editor/tests/unit/services/webMcpToolAdapter.test.ts b/apps/editor/tests/unit/services/webMcpToolAdapter.test.ts index db7ddade..0bbab8bd 100644 --- a/apps/editor/tests/unit/services/webMcpToolAdapter.test.ts +++ b/apps/editor/tests/unit/services/webMcpToolAdapter.test.ts @@ -1,83 +1,118 @@ -import { promptRecipes } from '../../../src/ui/editor/prompting/promptRecipes'; +import { authoringAutomationController } from '../../../src/services/automation/authoringAutomationController'; +import type { AuthoringAutomationDelegate } from '../../../src/services/automation/authoringAutomationController'; import { WebMcpToolAdapter, type WebMcpTool } from '../../../src/services/webmcp/webMcpToolAdapter'; -describe('WebMcpToolAdapter', () => { - function createAdapter() { - return new WebMcpToolAdapter({ - createProject: vi.fn(), - generateSlides: vi.fn(), - generateImage: vi.fn(), - translateText: vi.fn(), - getProjectSnapshot: vi.fn(), - }); - } +const expectedToolNames = [ + 'create_presentation', + 'get_presentation_state', + 'import_powerpoint_from_url', + 'translate_deck_and_notes', + 'generate_deck_detailed_description', + 'list_authoring_catalog', + 'upsert_slide_content', + 'generate_image', + 'get_slide_preview', + 'get_ai_model_status', + 'prepare_ai_models', + 'search_media', + 'export_presentation', + 'publish_presentation', + 'get_operation_status', +]; - it('registers discoverable WebMCP tools with prompt examples in metadata', () => { - const registerTools = vi.fn<(tools: WebMcpTool[]) => void>(); - const adapter = createAdapter(); +function createDelegate( + overrides: Partial = {}, +): AuthoringAutomationDelegate { + return { + createPresentation: vi.fn(() => ({ projectId: 'project-1', name: 'Untitled' })), + getPresentationState: vi.fn(() => ({ projectId: 'project-1', pageCount: 1 })), + importPowerPointFromUrl: vi.fn(() => Promise.resolve({ pageCount: 1 })), + translateDeckAndNotes: vi.fn(() => Promise.resolve({ translatedPageIds: [] })), + generateDeckDetailedDescription: vi.fn(() => Promise.resolve({ describedSlides: 1 })), + listAuthoringCatalog: vi.fn(() => ({ fonts: [] })), + upsertSlideContent: vi.fn(() => Promise.reject(new Error('not used'))), + generateImage: vi.fn(() => Promise.resolve({ assetId: 'asset-1' })), + getSlidePreview: vi.fn(() => ({ slideId: 'page-1' })), + getAiModelStatus: vi.fn(() => Promise.resolve({ models: [] })), + prepareAiModels: vi.fn(() => Promise.resolve([])), + searchMedia: vi.fn(() => Promise.resolve({ results: [] })), + exportPresentation: vi.fn(() => Promise.resolve({ fileName: 'deck.pptx' })), + publishPresentation: vi.fn(() => Promise.resolve({ publicUrl: 'https://example.test/deck' })), + ...overrides, + }; +} - adapter.register({ registerTools }); +function createAdapter(delegate = createDelegate()) { + return new WebMcpToolAdapter( + new authoringAutomationController.AuthoringAutomationController(delegate), + ); +} + +describe('WebMcpToolAdapter', () => { + it('registers only the refined authoring catalog with safety metadata', () => { + const registerTools = vi.fn<(tools: WebMcpTool[]) => void>(); + createAdapter().register({ registerTools }); const tools = registerTools.mock.calls[0]?.[0] ?? []; - expect(tools).toHaveLength(5); - expect(tools.map((tool) => tool.name)).toEqual([ - 'create_project', - 'generate_slides', - 'generate_image', - 'translate_text', - 'get_project_snapshot', - ]); - const generateSlidesTool = tools.find((tool) => tool.name === 'generate_slides'); - const generateImageTool = tools.find((tool) => tool.name === 'generate_image'); - expect(generateSlidesTool?.description).toContain(promptRecipes.slidePromptExamples[0]); - expect(generateImageTool?.description).toContain(promptRecipes.imagePromptExamples[0]); + expect(tools.map((tool) => tool.name)).toEqual(expectedToolNames); + expect(tools).toHaveLength(15); + expect(tools.every((tool) => Boolean(tool.title))).toBe(true); + expect(tools.every((tool) => tool.annotations?.untrustedContentHint)).toBe(true); + expect(tools.find((tool) => tool.name === 'generate_image')?.description).toContain( + 'upsert_slide_content', + ); }); - it('runs WebMCP cleanup callbacks returned by the browser runtime', () => { + it('runs cleanup callbacks returned by the browser runtime', () => { const cleanup = vi.fn(); - const adapter = createAdapter(); - const unregister = adapter.register({ - registerTools: vi.fn(() => cleanup), - }); + const unregister = createAdapter().register({ registerTools: vi.fn(() => cleanup) }); unregister(); expect(cleanup).toHaveBeenCalledTimes(1); }); - it('ignores duplicate WebMCP tool registration errors from an existing runtime registration', () => { - const adapter = createAdapter(); + it('ignores duplicate tool registration errors', () => { const registerTool = vi.fn(() => { throw new DOMException('Duplicate tool name', 'InvalidStateError'); }); - expect(() => adapter.register({ registerTool })).not.toThrow(); - expect(registerTool).toHaveBeenCalledTimes(5); + expect(() => createAdapter().register({ registerTool })).not.toThrow(); + expect(registerTool).toHaveBeenCalledTimes(15); }); - it('forwards tool calls to the automation controller', async () => { - const generateSlides = vi.fn(() => - Promise.resolve({ ok: true as const, data: { snapshot: { projectId: 'project-1' } as never } }), + it('normalizes create presentation input before forwarding it', async () => { + const createPresentation = vi.fn(() => ({ projectId: 'project-1', name: 'WebMCP Deck' })); + const adapter = createAdapter(createDelegate({ createPresentation })); + const tool = adapter + .createTools() + .find((candidate) => candidate.name === 'create_presentation'); + + await expect(tool?.execute({ name: 'WebMCP Deck', width: 1600, height: 900 })).resolves.toEqual( + { + ok: true, + data: { projectId: 'project-1', name: 'WebMCP Deck' }, + }, ); - const registerTools = vi.fn<(tools: WebMcpTool[]) => void>(); - const adapter = new WebMcpToolAdapter({ - createProject: vi.fn(), - generateSlides, - generateImage: vi.fn(), - translateText: vi.fn(), - getProjectSnapshot: vi.fn(), + expect(createPresentation).toHaveBeenCalledWith({ + name: 'WebMCP Deck', + width: 1600, + height: 900, }); + }); - adapter.register({ registerTools }); - const tools = registerTools.mock.calls[0]?.[0] ?? []; - const generateSlidesTool = tools.find((tool) => tool.name === 'generate_slides'); + it('publishes a strict discriminated schema for slide upserts', () => { + const tool = createAdapter() + .createTools() + .find((candidate) => candidate.name === 'upsert_slide_content'); + const schema = tool?.inputSchema as { + additionalProperties?: boolean; + oneOf?: unknown[]; + properties?: { elements?: { items?: { oneOf?: unknown[] } } }; + }; - await expect(generateSlidesTool?.execute({ prompt: 'Three-image grid about Web AI, with matching captions.' })).resolves.toEqual({ - ok: true, - data: { snapshot: { projectId: 'project-1' } }, - }); - expect(generateSlides).toHaveBeenCalledWith({ - prompt: 'Three-image grid about Web AI, with matching captions.', - }); + expect(schema.additionalProperties).toBe(false); + expect(schema.oneOf).toHaveLength(2); + expect(schema.properties?.elements?.items?.oneOf).toHaveLength(5); }); }); diff --git a/apps/editor/tests/unit/ui/editor/EditorShell.test.tsx b/apps/editor/tests/unit/ui/editor/EditorShell.test.tsx index 16da3d57..1da1ed52 100644 --- a/apps/editor/tests/unit/ui/editor/EditorShell.test.tsx +++ b/apps/editor/tests/unit/ui/editor/EditorShell.test.tsx @@ -9,13 +9,26 @@ import type { import { EditorShell } from '../../../../src/ui/editor/shell/EditorShell'; import { editorShellTestHarness } from './EditorShell.test-harness'; -const { - createAppServices, - openLeftTab, - selectImageLayer, -} = editorShellTestHarness; +const { createAppServices, openLeftTab, selectImageLayer } = editorShellTestHarness; const originalMatchMedia = window.matchMedia; +const authoringToolNames = [ + 'create_presentation', + 'get_presentation_state', + 'import_powerpoint_from_url', + 'translate_deck_and_notes', + 'generate_deck_detailed_description', + 'list_authoring_catalog', + 'upsert_slide_content', + 'generate_image', + 'get_slide_preview', + 'get_ai_model_status', + 'prepare_ai_models', + 'search_media', + 'export_presentation', + 'publish_presentation', + 'get_operation_status', +]; describe('EditorShell', () => { afterEach(() => { @@ -54,13 +67,7 @@ describe('EditorShell', () => { expect(registerTools).toHaveBeenCalled(); }); const tools = registerTools.mock.calls[0]?.[0] ?? []; - expect(tools.map((tool) => tool.name)).toEqual([ - 'create_project', - 'generate_slides', - 'generate_image', - 'translate_text', - 'get_project_snapshot', - ]); + expect(tools.map((tool) => tool.name)).toEqual(authoringToolNames); }); it('can disable WebMCP protocol registration from the editor URL', async () => { @@ -86,15 +93,11 @@ describe('EditorShell', () => { render(); await waitFor(() => { - expect((window as WebMcpDemoWindow).localStudioWebMcpTools).toHaveLength(5); + expect((window as WebMcpDemoWindow).localStudioWebMcpTools).toHaveLength(15); }); - expect((window as WebMcpDemoWindow).localStudioWebMcpTools?.map((tool) => tool.name)).toEqual([ - 'create_project', - 'generate_slides', - 'generate_image', - 'translate_text', - 'get_project_snapshot', - ]); + expect((window as WebMcpDemoWindow).localStudioWebMcpTools?.map((tool) => tool.name)).toEqual( + authoringToolNames, + ); }); it('keeps WebMCP embeds available inside narrow host frames', async () => { @@ -115,7 +118,7 @@ describe('EditorShell', () => { expect( screen.queryByRole('heading', { name: 'Open this workspace on a desktop screen.' }), ).not.toBeInTheDocument(); - expect((window as WebMcpDemoWindow).localStudioWebMcpTools).toHaveLength(5); + expect((window as WebMcpDemoWindow).localStudioWebMcpTools).toHaveLength(15); }); it('renders the approved editor shell landmarks', async () => { diff --git a/tests/e2e/editor/coverage-service-contracts.spec.ts b/tests/e2e/editor/coverage-service-contracts.spec.ts index 2a0d83ec..585dca8a 100644 --- a/tests/e2e/editor/coverage-service-contracts.spec.ts +++ b/tests/e2e/editor/coverage-service-contracts.spec.ts @@ -41,7 +41,10 @@ import { evaluateProgressContract } from './progress-contract-browser'; import { evaluateProjectMutationUtilsContract } from './project-mutation-utils-contract-browser'; import { evaluateSampleProjectContract } from './sample-project-contract-browser'; import { serviceContractsSupport } from './service-contracts-support'; -import { createMirrorStorageContractProject, createStorageContractProject } from './storage-contract-project'; +import { + createMirrorStorageContractProject, + createStorageContractProject, +} from './storage-contract-project'; import { storageContractRuntimePage } from './storage-contract-runtime-page'; import { evaluateStorageDisabledContract } from './storage-disabled-contract-browser'; import { evaluateStorageMirrorImportContract } from './storage-mirror-import-contract-browser'; @@ -154,8 +157,16 @@ test.describe('editor service contracts coverage batch', () => { pptx: pptxPatcherContractFixtures.createInput(), }); - expect(result.generatedTexts).toEqual(['plain', 'chat content', 'nested chat', 'object text']); - expect(result.detectedLanguages).toEqual([{ language: 'pt', score: 0.91 }, { language: 'en' }]); + expect(result.generatedTexts).toEqual([ + 'plain', + 'chat content', + 'nested chat', + 'object text', + ]); + expect(result.detectedLanguages).toEqual([ + { language: 'pt', score: 0.91 }, + { language: 'en' }, + ]); expect(result.parsingErrors).toHaveLength(2); expect(result.patchedWarningCodes).toEqual( expect.arrayContaining([ @@ -202,10 +213,7 @@ test.describe('editor service contracts coverage batch', () => { expect(result.session.stateCount).toBeGreaterThan(0); expect(result.speech).toMatchObject({ changedSpeechLanguage: 'en-US', - errors: [ - 'Microphone permission is required for live transcription.', - 'Network down', - ], + errors: ['Microphone permission is required for live transcription.', 'Network down'], text: 'ola mundo', }); expect(result.speech.updates).toContainEqual({ final: false, text: 'ola mundo' }); @@ -322,7 +330,11 @@ test.describe('editor service contracts coverage batch', () => { translationState: { progress: 100, status: 'ready' }, }); expect(download.modelLoads).toEqual( - expect.arrayContaining(['image-editing', 'image-generation', expect.stringMatching(/^text:/)]), + expect.arrayContaining([ + 'image-editing', + 'image-generation', + expect.stringMatching(/^text:/), + ]), ); expect(download.storageWrites).toEqual( expect.arrayContaining([expect.stringMatching(/:true$/)]), @@ -462,7 +474,10 @@ test.describe('editor service contracts coverage batch', () => { expect.arrayContaining([expect.objectContaining({ progress: 50 })]), ); - const language = await transformersRuntimeContractPage.runLanguageWorkerContract(page, baseURL); + const language = await transformersRuntimeContractPage.runLanguageWorkerContract( + page, + baseURL, + ); expect(language).toMatchObject({ detectedLanguage: { language: 'es', score: 0.92 }, requests: ['preload-language-detection', 'detect-language'], @@ -551,12 +566,16 @@ test.describe('editor service contracts coverage batch', () => { expect.arrayContaining([ expect.stringContaining('info:[LocalStudio presenter remote]|enabled'), expect.stringContaining('warn:[LocalStudio presenter remote]|object|{"ok":true}'), - expect.stringContaining('error:[LocalStudio presenter remote]|failure|TypeError: bad stream'), + expect.stringContaining( + 'error:[LocalStudio presenter remote]|failure|TypeError: bad stream', + ), expect.stringContaining('warn:[LocalStudio presenter remote]|circular|[object Object]'), ]), ); expect(logging.logs).not.toEqual( - expect.arrayContaining([expect.stringContaining('info:[LocalStudio presenter remote]|ready')]), + expect.arrayContaining([ + expect.stringContaining('info:[LocalStudio presenter remote]|ready'), + ]), ); await gotoNewProject(page); @@ -726,7 +745,9 @@ test.describe('editor service contracts coverage batch', () => { trustedOffer: { status: 'pending' }, untrustedOffer: { status: 'not-found' }, }); - expect(offer.pendingOffers).toEqual([{ controllerId: 'controller-1', offerSdp: 'offer-sdp' }]); + expect(offer.pendingOffers).toEqual([ + { controllerId: 'controller-1', offerSdp: 'offer-sdp' }, + ]); const answer = await presenterSignalingWebRtcContractPage.runAnswer(page, options); expect(answer).toMatchObject({ @@ -756,13 +777,23 @@ test.describe('editor service contracts coverage batch', () => { evaluateWebMcpToolAdapterMetadataContract, ); expect(metadata.toolNames).toEqual([ - 'create_project', - 'generate_slides', + 'create_presentation', + 'get_presentation_state', + 'import_powerpoint_from_url', + 'translate_deck_and_notes', + 'generate_deck_detailed_description', + 'list_authoring_catalog', + 'upsert_slide_content', 'generate_image', - 'translate_text', - 'get_project_snapshot', + 'get_slide_preview', + 'get_ai_model_status', + 'prepare_ai_models', + 'search_media', + 'export_presentation', + 'publish_presentation', + 'get_operation_status', ]); - expect(metadata.toolDescriptions.join('\n')).toContain('Good prompt examples'); + expect(metadata.toolTitles.every(Boolean)).toBe(true); const execution = await webMcpContractPage.run( page, @@ -770,22 +801,16 @@ test.describe('editor service contracts coverage batch', () => { evaluateWebMcpToolAdapterExecutionContract, ); expect(execution).toMatchObject({ - createProjectBlank: { data: { name: 'Untitled' }, ok: true }, - createProjectNamed: { data: { name: 'WebMCP Deck' }, ok: true }, - generatedImage: { data: { assetId: 'asset-generated' }, ok: true }, - generatedSlides: { data: { prompt: 'Create a launch slide' }, ok: true }, - snapshot: { data: { pageCount: 1 }, ok: true }, - translated: { data: { scope: 'slide' }, ok: true }, - translatedWithoutPage: { data: { scope: 'deck' }, ok: true }, + created: { data: { name: 'WebMCP Deck' }, ok: true }, + state: { data: { pageCount: 1 }, ok: true }, + preview: { data: { slideId: 'page-1' }, ok: true }, + imageStatus: { data: { state: 'completed' }, ok: true }, }); expect(execution.controllerCalls.map((call) => call.name)).toEqual([ - 'createProject', - 'createProject', - 'generateSlides', + 'createPresentation', + 'getPresentationState', + 'getSlidePreview', 'generateImage', - 'translateText', - 'translateText', - 'getProjectSnapshot', ]); const registration = await webMcpContractPage.run( @@ -793,13 +818,7 @@ test.describe('editor service contracts coverage batch', () => { baseURL, evaluateWebMcpToolAdapterRegistrationContract, ); - expect(registration.registeredNames).toEqual([ - 'create_project', - 'generate_slides', - 'generate_image', - 'translate_text', - 'get_project_snapshot', - ]); + expect(registration.registeredNames).toEqual(metadata.toolNames); expect(registration.individuallyRegisteredNames).toEqual(registration.registeredNames); }); }); diff --git a/tests/e2e/editor/service-contracts-webmcp.spec.ts b/tests/e2e/editor/service-contracts-webmcp.spec.ts index eacbd250..71ec847c 100644 --- a/tests/e2e/editor/service-contracts-webmcp.spec.ts +++ b/tests/e2e/editor/service-contracts-webmcp.spec.ts @@ -5,9 +5,7 @@ import { evaluateWebMcpToolAdapterExecutionContract } from './webmcp-tool-adapte import { evaluateWebMcpToolAdapterMetadataContract } from './webmcp-tool-adapter-metadata-contract-browser'; import { evaluateWebMcpToolAdapterRegistrationContract } from './webmcp-tool-adapter-registration-contract-browser'; -test('executes WebMCP tool adapter metadata contracts in the browser runtime', async ({ - page, -}) => { +test('executes WebMCP tool adapter metadata contracts in the browser runtime', async ({ page }) => { const result = await webMcpContractPage.run( page, serviceContractsSupport.getServer().baseURL, @@ -15,13 +13,31 @@ test('executes WebMCP tool adapter metadata contracts in the browser runtime', a ); expect(result.toolNames).toEqual([ - 'create_project', - 'generate_slides', + 'create_presentation', + 'get_presentation_state', + 'import_powerpoint_from_url', + 'translate_deck_and_notes', + 'generate_deck_detailed_description', + 'list_authoring_catalog', + 'upsert_slide_content', 'generate_image', - 'translate_text', - 'get_project_snapshot', + 'get_slide_preview', + 'get_ai_model_status', + 'prepare_ai_models', + 'search_media', + 'export_presentation', + 'publish_presentation', + 'get_operation_status', + ]); + expect(result.toolTitles.every(Boolean)).toBe(true); + expect(result.readOnlyNames).toEqual([ + 'get_presentation_state', + 'list_authoring_catalog', + 'get_slide_preview', + 'get_ai_model_status', + 'search_media', + 'get_operation_status', ]); - expect(result.toolDescriptions.join('\n')).toContain('Good prompt examples'); }); test('executes WebMCP tool adapter execution contracts in the browser runtime', async ({ @@ -34,22 +50,17 @@ test('executes WebMCP tool adapter execution contracts in the browser runtime', ); expect(result).toMatchObject({ - createProjectBlank: { data: { name: 'Untitled' }, ok: true }, - createProjectNamed: { data: { name: 'WebMCP Deck' }, ok: true }, - generatedImage: { data: { assetId: 'asset-generated' }, ok: true }, - generatedSlides: { data: { prompt: 'Create a launch slide' }, ok: true }, - snapshot: { data: { pageCount: 1 }, ok: true }, - translated: { data: { scope: 'slide' }, ok: true }, - translatedWithoutPage: { data: { scope: 'deck' }, ok: true }, + created: { data: { name: 'WebMCP Deck', projectId: 'project-1' }, ok: true }, + state: { data: { pageCount: 1, projectId: 'project-1' }, ok: true }, + preview: { data: { slideId: 'page-1', slideNumber: 1 }, ok: true }, + imageOperation: { data: { status: 'queued' }, ok: true }, + imageStatus: { data: { state: 'completed', result: { assetId: 'asset-generated' } }, ok: true }, }); expect(result.controllerCalls.map((call) => call.name)).toEqual([ - 'createProject', - 'createProject', - 'generateSlides', + 'createPresentation', + 'getPresentationState', + 'getSlidePreview', 'generateImage', - 'translateText', - 'translateText', - 'getProjectSnapshot', ]); }); @@ -63,15 +74,25 @@ test('executes WebMCP tool adapter registration contracts in the browser runtime ); expect(result.registeredNames).toEqual([ - 'create_project', - 'generate_slides', + 'create_presentation', + 'get_presentation_state', + 'import_powerpoint_from_url', + 'translate_deck_and_notes', + 'generate_deck_detailed_description', + 'list_authoring_catalog', + 'upsert_slide_content', 'generate_image', - 'translate_text', - 'get_project_snapshot', + 'get_slide_preview', + 'get_ai_model_status', + 'prepare_ai_models', + 'search_media', + 'export_presentation', + 'publish_presentation', + 'get_operation_status', ]); expect(result.individuallyRegisteredNames).toEqual(result.registeredNames); - expect(result.batchCleanupCount).toBe(5); - expect(result.individualCleanupCount).toBe(5); + expect(result.batchCleanupCount).toBe(15); + expect(result.individualCleanupCount).toBe(15); expect(result.duplicateBatchIgnored).toBe(true); expect(result.duplicateIndividualIgnored).toBe(true); expect(result.nonDuplicateErrorName).toBe('registration failed'); diff --git a/tests/e2e/editor/webmcp-tool-adapter-execution-contract-browser.ts b/tests/e2e/editor/webmcp-tool-adapter-execution-contract-browser.ts index 0a2653a8..c9ab2615 100644 --- a/tests/e2e/editor/webmcp-tool-adapter-execution-contract-browser.ts +++ b/tests/e2e/editor/webmcp-tool-adapter-execution-contract-browser.ts @@ -1,73 +1,70 @@ export type WebMcpToolAdapterExecutionContractResult = { controllerCalls: Array<{ input: unknown; name: string }>; - createProjectBlank: unknown; - createProjectNamed: unknown; - generatedImage: unknown; - generatedSlides: unknown; - snapshot: unknown; - translated: unknown; - translatedWithoutPage: unknown; + created: unknown; + imageOperation: unknown; + imageStatus: unknown; + preview: unknown; + state: unknown; }; export async function evaluateWebMcpToolAdapterExecutionContract(): Promise { - const { WebMcpToolAdapter } = (await import( - '/editor/src/services/webmcp/webMcpToolAdapter.ts' - )) as typeof import('../../../apps/editor/src/services/webmcp/webMcpToolAdapter'); - + const [{ authoringAutomationController }, { WebMcpToolAdapter }] = (await Promise.all([ + import('/editor/src/services/automation/authoringAutomationController.ts'), + import('/editor/src/services/webmcp/webMcpToolAdapter.ts'), + ])) as [ + typeof import('../../../apps/editor/src/services/automation/authoringAutomationController'), + typeof import('../../../apps/editor/src/services/webmcp/webMcpToolAdapter'), + ]; const controllerCalls: Array<{ input: unknown; name: string }> = []; - const adapter = new WebMcpToolAdapter({ - createProject: (input) => { - controllerCalls.push({ input, name: 'createProject' }); - return { data: { name: input.name ?? 'Untitled' }, ok: true }; - }, - generateImage: (input) => { - controllerCalls.push({ input, name: 'generateImage' }); - return { data: { assetId: 'asset-generated' }, ok: true }; + const unused = () => Promise.resolve({}); + const controller = new authoringAutomationController.AuthoringAutomationController({ + createPresentation: (input) => { + controllerCalls.push({ input, name: 'createPresentation' }); + return { projectId: 'project-1', name: input.name ?? 'Untitled' }; }, - generateSlides: (input) => { - controllerCalls.push({ input, name: 'generateSlides' }); - return { data: { prompt: input.prompt }, ok: true }; + getPresentationState: (input) => { + controllerCalls.push({ input, name: 'getPresentationState' }); + return { projectId: 'project-1', pageCount: 1 }; }, - getProjectSnapshot: () => { - controllerCalls.push({ input: {}, name: 'getProjectSnapshot' }); - return { data: { pageCount: 1 }, ok: true }; + importPowerPointFromUrl: unused, + translateDeckAndNotes: unused, + generateDeckDetailedDescription: unused, + listAuthoringCatalog: unused, + upsertSlideContent: () => Promise.reject(new Error('unused')), + generateImage: (input, report) => { + controllerCalls.push({ input, name: 'generateImage' }); + report({ stage: 'generating-image', progress: 70 }); + return Promise.resolve({ assetId: 'asset-generated' }); }, - translateText: (input) => { - controllerCalls.push({ input, name: 'translateText' }); - return { data: { scope: input.scope }, ok: true }; + getSlidePreview: (input) => { + controllerCalls.push({ input, name: 'getSlidePreview' }); + return { slideId: 'page-1', slideNumber: input.slideNumber }; }, + getAiModelStatus: unused, + prepareAiModels: () => Promise.resolve([]), + searchMedia: unused, + exportPresentation: unused, + publishPresentation: unused, }); - const tools = adapter.createTools(); - const createProjectNamed = await tools[0].execute({ name: 'WebMCP Deck' }); - const createProjectBlank = await tools[0].execute({ name: 123 }); - const generatedSlides = await tools[1].execute({ prompt: 'Create a launch slide' }); - const generatedImage = await tools[2].execute({ - height: 512, - prompt: 'neon card', - seed: Number.NaN, - steps: 8, - width: 512, - }); - const translated = await tools[3].execute({ - pageId: 'page-1', - scope: 'slide', - targetLanguage: 'pt', - }); - const translatedWithoutPage = await tools[3].execute({ - pageId: 5, - scope: 'deck', - targetLanguage: 'es', - }); - const snapshot = await tools[4].execute({}); + const tools = new WebMcpToolAdapter(controller).createTools(); + const byName = new Map(tools.map((tool) => [tool.name, tool])); + const created = await byName + .get('create_presentation')! + .execute({ name: 'WebMCP Deck', width: 1600, height: 900 }); + const state = await byName.get('get_presentation_state')!.execute({ detail: 'summary' }); + const preview = await byName.get('get_slide_preview')!.execute({ slideNumber: 1 }); + const imageOperation = await byName + .get('generate_image')! + .execute({ prompt: 'neon card', width: 512, height: 512 }); + await new Promise((resolve) => window.setTimeout(resolve, 0)); + const operationId = + imageOperation.ok && + typeof imageOperation.data === 'object' && + imageOperation.data && + 'operationId' in imageOperation.data + ? String(imageOperation.data.operationId) + : ''; + const imageStatus = await byName.get('get_operation_status')!.execute({ operationId }); - return { - controllerCalls, - createProjectBlank, - createProjectNamed, - generatedImage, - generatedSlides, - snapshot, - translated, - translatedWithoutPage, - }; + return { controllerCalls, created, imageOperation, imageStatus, preview, state }; } diff --git a/tests/e2e/editor/webmcp-tool-adapter-metadata-contract-browser.ts b/tests/e2e/editor/webmcp-tool-adapter-metadata-contract-browser.ts index ac60e1e6..dfc2a9d9 100644 --- a/tests/e2e/editor/webmcp-tool-adapter-metadata-contract-browser.ts +++ b/tests/e2e/editor/webmcp-tool-adapter-metadata-contract-browser.ts @@ -1,24 +1,41 @@ export type WebMcpToolAdapterMetadataContractResult = { + readOnlyNames: string[]; toolDescriptions: string[]; toolNames: string[]; + toolTitles: string[]; }; export async function evaluateWebMcpToolAdapterMetadataContract(): Promise { - const { WebMcpToolAdapter } = (await import( - '/editor/src/services/webmcp/webMcpToolAdapter.ts' - )) as typeof import('../../../apps/editor/src/services/webmcp/webMcpToolAdapter'); - - const adapter = new WebMcpToolAdapter({ - createProject: () => ({ data: {}, ok: true }), - generateImage: () => ({ data: {}, ok: true }), - generateSlides: () => ({ data: {}, ok: true }), - getProjectSnapshot: () => ({ data: {}, ok: true }), - translateText: () => ({ data: {}, ok: true }), + const [{ authoringAutomationController }, { WebMcpToolAdapter }] = (await Promise.all([ + import('/editor/src/services/automation/authoringAutomationController.ts'), + import('/editor/src/services/webmcp/webMcpToolAdapter.ts'), + ])) as [ + typeof import('../../../apps/editor/src/services/automation/authoringAutomationController'), + typeof import('../../../apps/editor/src/services/webmcp/webMcpToolAdapter'), + ]; + const unused = () => Promise.resolve({}); + const controller = new authoringAutomationController.AuthoringAutomationController({ + createPresentation: unused, + getPresentationState: unused, + importPowerPointFromUrl: unused, + translateDeckAndNotes: unused, + generateDeckDetailedDescription: unused, + listAuthoringCatalog: unused, + upsertSlideContent: () => Promise.reject(new Error('unused')), + generateImage: unused, + getSlidePreview: unused, + getAiModelStatus: unused, + prepareAiModels: () => Promise.resolve([]), + searchMedia: unused, + exportPresentation: unused, + publishPresentation: unused, }); - const tools = adapter.createTools(); + const tools = new WebMcpToolAdapter(controller).createTools(); return { + readOnlyNames: tools.filter((tool) => tool.annotations?.readOnlyHint).map((tool) => tool.name), toolDescriptions: tools.map((tool) => tool.description), toolNames: tools.map((tool) => tool.name), + toolTitles: tools.map((tool) => tool.title), }; } diff --git a/tests/e2e/editor/webmcp-tool-adapter-registration-contract-browser.ts b/tests/e2e/editor/webmcp-tool-adapter-registration-contract-browser.ts index 6c4ab267..42609dfd 100644 --- a/tests/e2e/editor/webmcp-tool-adapter-registration-contract-browser.ts +++ b/tests/e2e/editor/webmcp-tool-adapter-registration-contract-browser.ts @@ -9,17 +9,32 @@ export type WebMcpToolAdapterRegistrationContractResult = { }; export async function evaluateWebMcpToolAdapterRegistrationContract(): Promise { - const { WebMcpToolAdapter } = (await import( - '/editor/src/services/webmcp/webMcpToolAdapter.ts' - )) as typeof import('../../../apps/editor/src/services/webmcp/webMcpToolAdapter'); - - const adapter = new WebMcpToolAdapter({ - createProject: () => ({ data: {}, ok: true }), - generateImage: () => ({ data: {}, ok: true }), - generateSlides: () => ({ data: {}, ok: true }), - getProjectSnapshot: () => ({ data: {}, ok: true }), - translateText: () => ({ data: {}, ok: true }), - }); + const [{ authoringAutomationController }, { WebMcpToolAdapter }] = (await Promise.all([ + import('/editor/src/services/automation/authoringAutomationController.ts'), + import('/editor/src/services/webmcp/webMcpToolAdapter.ts'), + ])) as [ + typeof import('../../../apps/editor/src/services/automation/authoringAutomationController'), + typeof import('../../../apps/editor/src/services/webmcp/webMcpToolAdapter'), + ]; + const unused = () => Promise.resolve({}); + const adapter = new WebMcpToolAdapter( + new authoringAutomationController.AuthoringAutomationController({ + createPresentation: unused, + getPresentationState: unused, + importPowerPointFromUrl: unused, + translateDeckAndNotes: unused, + generateDeckDetailedDescription: unused, + listAuthoringCatalog: unused, + upsertSlideContent: () => Promise.reject(new Error('unused')), + generateImage: unused, + getSlidePreview: unused, + getAiModelStatus: unused, + prepareAiModels: () => Promise.resolve([]), + searchMedia: unused, + exportPresentation: unused, + publishPresentation: unused, + }), + ); const registeredNames: string[] = []; let batchCleanupCount = 0; const unregisterBatch = adapter.register({ @@ -31,16 +46,14 @@ export async function evaluateWebMcpToolAdapterRegistrationContract(): Promise { adapter.register({ registerTools: () => { - throw new DOMException('Duplicate tool name: create_project', 'InvalidStateError'); + throw new DOMException('Duplicate tool name', 'InvalidStateError'); }, }); return true; })(); - const individuallyRegisteredNames: string[] = []; let individualCleanupCount = 0; const unregisterIndividual = adapter.register({ @@ -52,16 +65,14 @@ export async function evaluateWebMcpToolAdapterRegistrationContract(): Promise { adapter.register({ registerTool: () => { - throw new DOMException('Duplicate tool name: create_project', 'InvalidStateError'); + throw new DOMException('Duplicate tool name', 'InvalidStateError'); }, }); return true; })(); - let nonDuplicateErrorName = ''; try { adapter.register({ @@ -72,7 +83,6 @@ export async function evaluateWebMcpToolAdapterRegistrationContract(): Promise { ).toBeVisible(); await page.getByRole('button', { name: 'Discover tools' }).click(); await expect(page.getByText(/Discovered \d+ tools/)).toBeVisible(); - await expect(page.getByRole('button', { name: 'create_project' })).toBeVisible(); - await page.getByRole('button', { name: 'Create project' }).click(); - await expect(page.getByLabel('Create project command input')).toBeVisible(); - await page.getByLabel('Create project command input').fill('E2E WebMCP project'); - await page.getByRole('button', { name: 'Send Create project' }).click(); - await expect(page.getByText('Create project completed.')).toBeVisible(); + await expect(page.getByRole('button', { name: 'create_presentation' })).toBeVisible(); + await expect(page.getByRole('button', { name: 'import_powerpoint_from_disk' })).toHaveCount(0); + await page.getByRole('button', { name: 'Create presentation' }).click(); + await expect(page.getByLabel('Create presentation command input')).toBeVisible(); + await page.getByLabel('Create presentation command input').fill('E2E WebMCP project'); + await page.getByRole('button', { name: 'Send Create presentation' }).click(); + await expect(page.getByText('Create presentation completed.')).toBeVisible(); await expect(page.getByRole('region', { name: 'Last WebMCP result' })).toContainText( 'E2E WebMCP project', ); - await page.getByRole('button', { name: 'get_project_snapshot' }).click(); - await page.getByRole('button', { name: 'Read snapshot' }).click(); - await expect(page.getByText('Read snapshot completed.')).toBeVisible(); + await page.getByRole('button', { name: 'Upsert slide' }).click(); + await expect(page.getByLabel('Upsert slide command input')).toBeVisible(); + await page.getByRole('button', { name: 'Send Upsert slide' }).click(); + await expect(page.getByText('Upsert slide completed.')).toBeVisible(); + await expect(page.getByRole('region', { name: 'Last WebMCP result' })).toContainText( + 'idempotentReplay', + ); + await expect( + page + .frameLocator('iframe[title="LocalStudio editor WebMCP demo"]') + .getByText(/Page 1.*Agent-native presentations/), + ).toBeVisible(); + + await page.getByRole('button', { name: 'get_presentation_state' }).click(); + await page.getByRole('button', { name: 'Read presentation state' }).click(); + await expect(page.getByText('Read presentation state completed.')).toBeVisible(); await expect(page.getByRole('region', { name: 'Last WebMCP result' })).toContainText( 'E2E WebMCP project', ); + await expect(page.getByRole('region', { name: 'Last WebMCP result' })).toContainText( + 'Presentations become agent-native', + ); }); }); From 7f5ac9a3354bf9c888cd30c49dfb4e983fea7f97 Mon Sep 17 00:00:00 2001 From: Erick Wendel Date: Wed, 26 Aug 2026 17:20:02 -0300 Subject: [PATCH 2/2] fix(webmcp): harden authoring upserts Scope request replay state to the current presentation, report actual replace deletions, and derive description freshness from complete slide revisions. Add regression coverage for ordering, native primitives, slide gaps, and retry reset behavior. --- .../authoringAutomationController.ts | 5 +- .../createAuthoringAutomationDelegate.ts | 81 ++++--- .../services/automation/slideUpsertService.ts | 4 +- .../authoringAutomationController.test.ts | 36 ++++ .../createAuthoringAutomationDelegate.test.ts | 26 +++ .../unit/services/slideUpsertService.test.ts | 204 +++++++++++++++++- 6 files changed, 321 insertions(+), 35 deletions(-) diff --git a/apps/editor/src/services/automation/authoringAutomationController.ts b/apps/editor/src/services/automation/authoringAutomationController.ts index 72844055..5948a8a1 100644 --- a/apps/editor/src/services/automation/authoringAutomationController.ts +++ b/apps/editor/src/services/automation/authoringAutomationController.ts @@ -120,7 +120,10 @@ class AuthoringAutomationController { height?: number | undefined; }): Promise> { try { - return success(await this.delegate.createPresentation(input)); + await Promise.allSettled([...this.upserts.values()].map(({ promise }) => promise)); + const result = await this.delegate.createPresentation(input); + this.upserts.clear(); + return success(result); } catch (error) { return failure('create_presentation', this.describeError(error)); } diff --git a/apps/editor/src/services/automation/createAuthoringAutomationDelegate.ts b/apps/editor/src/services/automation/createAuthoringAutomationDelegate.ts index fbe35e32..bbb7bd6c 100644 --- a/apps/editor/src/services/automation/createAuthoringAutomationDelegate.ts +++ b/apps/editor/src/services/automation/createAuthoringAutomationDelegate.ts @@ -45,15 +45,29 @@ function validateRemoteUrl(value: string) { function getSlideRevision(project: ProjectDocument, pageId: string) { const page = project.pages.find((candidate) => candidate.id === pageId); if (!page) return ''; + const elements = page.elementIds.map((elementId) => project.elements[elementId]); + const assetIds = new Set(); + if (page.background.type === 'asset') assetIds.add(page.background.assetId); + elements.forEach((element) => { + if (element && 'assetId' in element && typeof element.assetId === 'string') { + assetIds.add(element.assetId); + } + }); const value = JSON.stringify({ page: { + name: page.name, width: page.width, height: page.height, background: page.background, elementIds: page.elementIds, + transition: page.transition, + animationBuilds: page.animationBuilds, + layoutId: page.layoutId, speakerNotes: page.speakerNotes, + visible: page.visible, }, - elements: page.elementIds.map((elementId) => project.elements[elementId]), + elements, + assets: [...assetIds].sort().map((assetId) => project.assets[assetId]), }); let hash = 2166136261; for (let index = 0; index < value.length; index += 1) { @@ -83,37 +97,40 @@ function createPresentationState( const detailed = input.detail === 'elements'; const elementCursor = Math.floor(Math.max(0, input.elementCursor ?? 0)); const elementLimit = Math.floor(Math.max(1, Math.min(50, input.elementLimit ?? 25))); - const slides = candidates.slice(0, detailed ? 5 : 20).map(({ page, slideNumber }) => ({ - slideId: page.id, - slideNumber, - name: page.name, - width: page.width, - height: page.height, - background: page.background, - elementCount: page.elementIds.length, - speakerNotes: boundedText(page.speakerNotes), - semanticDescription: page.semanticDescription - ? { ...page.semanticDescription, text: boundedText(page.semanticDescription.text) } - : undefined, - descriptionFreshness: page.semanticDescription - ? page.semanticDescription.stale - ? 'stale' - : 'fresh' - : 'missing', - revision: getSlideRevision(project, page.id), - ...(detailed - ? { - elements: page.elementIds - .slice(elementCursor, elementCursor + elementLimit) - .map((elementId) => boundedElement(project.elements[elementId])) - .filter(Boolean), - nextElementCursor: - elementCursor + elementLimit < page.elementIds.length - ? elementCursor + elementLimit - : undefined, - } - : {}), - })); + const slides = candidates.slice(0, detailed ? 5 : 20).map(({ page, slideNumber }) => { + const revision = getSlideRevision(project, page.id); + return { + slideId: page.id, + slideNumber, + name: page.name, + width: page.width, + height: page.height, + background: page.background, + elementCount: page.elementIds.length, + speakerNotes: boundedText(page.speakerNotes), + semanticDescription: page.semanticDescription + ? { ...page.semanticDescription, text: boundedText(page.semanticDescription.text) } + : undefined, + descriptionFreshness: page.semanticDescription + ? page.semanticDescription.stale || page.semanticDescription.sourceRevision !== revision + ? 'stale' + : 'fresh' + : 'missing', + revision, + ...(detailed + ? { + elements: page.elementIds + .slice(elementCursor, elementCursor + elementLimit) + .map((elementId) => boundedElement(project.elements[elementId])) + .filter(Boolean), + nextElementCursor: + elementCursor + elementLimit < page.elementIds.length + ? elementCursor + elementLimit + : undefined, + } + : {}), + }; + }); return { projectId: project.id, name: project.name, diff --git a/apps/editor/src/services/automation/slideUpsertService.ts b/apps/editor/src/services/automation/slideUpsertService.ts index dc48dfe3..c71d60e1 100644 --- a/apps/editor/src/services/automation/slideUpsertService.ts +++ b/apps/editor/src/services/automation/slideUpsertService.ts @@ -390,6 +390,8 @@ async function apply( const previousElementIds = existingPage?.elementIds ?? []; const deleteIds = new Set(batch.deleteElementIds ?? []); if (batch.mode === 'replace') previousElementIds.forEach((elementId) => deleteIds.add(elementId)); + const upsertElementIds = new Set(batch.elements.map((element) => element.elementId)); + const deletedElementIds = [...deleteIds].filter((elementId) => !upsertElementIds.has(elementId)); const resolvedElements = await Promise.all( batch.elements.map(async (input) => ({ input, ...(await createElement(input, options)) })), @@ -475,7 +477,7 @@ async function apply( updatedElements: batch.elements.filter((element) => Boolean(project.elements[element.elementId]), ).length, - deletedElements: deleteIds.size, + deletedElements: deletedElementIds.length, elementCount: orderedElementIds.length, project: nextProject, }; diff --git a/apps/editor/tests/unit/services/authoringAutomationController.test.ts b/apps/editor/tests/unit/services/authoringAutomationController.test.ts index 76a937da..c21671c6 100644 --- a/apps/editor/tests/unit/services/authoringAutomationController.test.ts +++ b/apps/editor/tests/unit/services/authoringAutomationController.test.ts @@ -117,6 +117,42 @@ describe('authoringAutomationController', () => { expect(upsertSlideContent).toHaveBeenCalledTimes(1); }); + it('scopes idempotent request IDs to the current presentation', async () => { + const upsertSlideContent = vi.fn((input: SlideUpsertBatch) => + Promise.resolve({ + requestId: input.requestId, + slideId: 'page-1', + slideNumber: 1, + createdSlide: false, + createdElements: 0, + updatedElements: 0, + deletedElements: 0, + elementCount: 0, + project: {} as never, + }), + ); + const controller = new authoringAutomationController.AuthoringAutomationController( + createDelegate({ upsertSlideContent }), + ); + const input: SlideUpsertBatch = { + requestId: 'reusable-after-create', + slideNumber: 1, + mode: 'merge', + elements: [], + }; + + await expect(controller.upsertSlideContent(input)).resolves.toMatchObject({ + ok: true, + data: { idempotentReplay: false }, + }); + await controller.createPresentation({ name: 'A different deck' }); + await expect(controller.upsertSlideContent(input)).resolves.toMatchObject({ + ok: true, + data: { idempotentReplay: false }, + }); + expect(upsertSlideContent).toHaveBeenCalledTimes(2); + }); + it('rejects a reused request ID when the batch body changes', async () => { const upsertSlideContent = vi.fn((input: SlideUpsertBatch) => Promise.resolve({ diff --git a/apps/editor/tests/unit/services/createAuthoringAutomationDelegate.test.ts b/apps/editor/tests/unit/services/createAuthoringAutomationDelegate.test.ts index f1ebddf0..6d0442cf 100644 --- a/apps/editor/tests/unit/services/createAuthoringAutomationDelegate.test.ts +++ b/apps/editor/tests/unit/services/createAuthoringAutomationDelegate.test.ts @@ -102,6 +102,32 @@ describe('createAuthoringAutomationDelegate', () => { }); }); + it('reports a description as stale when its source revision no longer matches the slide', async () => { + const harness = createHarness(); + const initialState = (await harness.delegate.getPresentationState({ detail: 'summary' })) as { + slides: Array<{ revision: string }>; + }; + const page = harness.getProject().pages[0]; + if (!page) throw new Error('Expected a first page.'); + page.semanticDescription = { + text: 'An old description', + language: 'en', + generatedAt: '2026-08-26T00:00:00.000Z', + generator: 'test', + sourceRevision: initialState.slides[0]?.revision ?? '', + reviewed: false, + stale: false, + }; + + expect(await harness.delegate.getPresentationState({ detail: 'summary' })).toMatchObject({ + slides: [{ slideNumber: 1, descriptionFreshness: 'fresh' }], + }); + page.name = 'Changed after description generation'; + expect(await harness.delegate.getPresentationState({ detail: 'summary' })).toMatchObject({ + slides: [{ slideNumber: 1, descriptionFreshness: 'stale' }], + }); + }); + it('downloads an available referenced font before applying the exact text element', async () => { const harness = createHarness(); diff --git a/apps/editor/tests/unit/services/slideUpsertService.test.ts b/apps/editor/tests/unit/services/slideUpsertService.test.ts index eb12b52f..8a89f8ab 100644 --- a/apps/editor/tests/unit/services/slideUpsertService.test.ts +++ b/apps/editor/tests/unit/services/slideUpsertService.test.ts @@ -55,7 +55,7 @@ describe('slideUpsertService', () => { expect(result.deletedElements).toBe(3); }); - it('merges batches, sorts z-indexes, and performs explicit deletion', async () => { + it('merges batches and performs explicit deletion', async () => { const project = sampleProject.createBlankProject(); const first = await slideUpsertService.apply( project, @@ -92,6 +92,190 @@ describe('slideUpsertService', () => { expect(second.deletedElements).toBe(1); }); + it('orders multiple elements and animation builds by their explicit order', async () => { + const project = sampleProject.createBlankProject(); + const result = await slideUpsertService.apply( + project, + { + requestId: 'ordered-elements', + slideNumber: 1, + mode: 'replace', + elements: [ + { + ...textElement, + zIndex: 5, + animations: [{ effect: 'fade' as const, order: 4 }], + }, + { + ...textElement, + elementId: 'subtitle', + zIndex: 1, + animations: [{ effect: 'wipe' as const, order: 2 }], + }, + ], + }, + options, + ); + + expect(result.project.pages[0]?.elementIds).toEqual(['subtitle', 'title']); + expect(result.project.pages[0]?.animationBuilds).toMatchObject([ + { elementId: 'subtitle', effect: 'wipe', order: 2 }, + { elementId: 'title', effect: 'fade', order: 4 }, + ]); + }); + + it('counts only elements actually removed during a replace', async () => { + const project = sampleProject.createBlankProject(); + const first = await slideUpsertService.apply( + project, + { + requestId: 'initial-stable-element', + slideNumber: 1, + mode: 'replace', + elements: [textElement], + }, + options, + ); + + const result = await slideUpsertService.apply( + first.project, + { + requestId: 'replace-stable-element', + slideNumber: 1, + mode: 'replace', + elements: [{ ...textElement, content: { text: 'Updated title' } }], + }, + options, + ); + + expect(result).toMatchObject({ + createdElements: 0, + updatedElements: 1, + deletedElements: 0, + elementCount: 1, + }); + }); + + it('maps shape and media primitives with their exact native properties', async () => { + const project = sampleProject.createBlankProject(); + const resolveMedia = vi.fn( + ( + _content: { assetId?: string; url?: string; mediaRef?: string }, + context: { elementId: string; type: 'gif' | 'image' | 'video' }, + ) => + Promise.resolve({ + id: `asset-${context.elementId}`, + type: context.type, + name: context.elementId, + mimeType: + context.type === 'video' + ? ('video/mp4' as const) + : context.type === 'gif' + ? ('image/gif' as const) + : ('image/png' as const), + objectUrl: `https://example.test/${context.elementId}`, + storage: 'remote' as const, + }), + ); + const frame = { x: 10, y: 20, width: 300, height: 200 }; + const result = await slideUpsertService.apply( + project, + { + requestId: 'native-primitives', + slideNumber: 1, + mode: 'replace', + elements: [ + { + elementId: 'shape', + type: 'shape', + frame, + zIndex: 0, + rotation: 15, + opacity: 0.7, + visible: false, + locked: true, + content: { shape: 'rounded-rect', fill: '#123456', stroke: '#abcdef', strokeWidth: 3 }, + }, + { + elementId: 'image', + type: 'image', + frame, + zIndex: 1, + content: { url: 'https://example.test/image.png' }, + crop: { x: 0.1, y: 0.2, width: 0.7, height: 0.6 }, + flipX: true, + mask: 'ellipse', + }, + { + elementId: 'gif', + type: 'gif', + frame, + zIndex: 2, + content: { url: 'https://example.test/animation.gif' }, + playing: false, + }, + { + elementId: 'video', + type: 'video', + frame, + zIndex: 3, + content: { url: 'https://example.test/video.mp4' }, + playback: { + loop: true, + controls: false, + muted: true, + autoplayInPreview: true, + trimStartSeconds: 2, + trimEndSeconds: 8, + playAcrossSlides: true, + startOnClick: true, + volume: 0.4, + }, + }, + ], + }, + { ...options, resolveMedia }, + ); + + expect(result.project.elements.shape).toMatchObject({ + type: 'shape', + shape: 'rounded-rect', + fill: '#123456', + stroke: '#abcdef', + strokeWidth: 3, + rotation: 15, + opacity: 0.7, + visible: false, + locked: true, + }); + expect(result.project.elements.image).toMatchObject({ + type: 'image', + assetId: 'asset-image', + crop: { x: 0.1, y: 0.2, width: 0.7, height: 0.6 }, + flipX: true, + mask: 'ellipse', + }); + expect(result.project.elements.gif).toMatchObject({ + type: 'gif', + assetId: 'asset-gif', + playing: false, + }); + expect(result.project.elements.video).toMatchObject({ + type: 'video', + assetId: 'asset-video', + loop: true, + controls: false, + muted: true, + autoplayInPreview: true, + trimStartSeconds: 2, + trimEndSeconds: 8, + playAcrossSlides: true, + startOnClick: true, + volume: 0.4, + }); + expect(resolveMedia).toHaveBeenCalledTimes(3); + }); + it('rejects the complete batch before mutation when IDs collide across slides', async () => { const project = sampleProject.createBlankProject(); const withSecondSlide = await slideUpsertService.apply( @@ -120,6 +304,24 @@ describe('slideUpsertService', () => { expect(withSecondSlide.project.pages[0]?.elementIds).toEqual([]); }); + it('rejects attempts to create a slide after a positional gap', async () => { + const project = sampleProject.createBlankProject(); + + await expect( + slideUpsertService.apply( + project, + { + requestId: 'slide-gap', + slideNumber: 3, + mode: 'replace', + elements: [], + }, + options, + ), + ).rejects.toThrow('cannot contain gaps'); + expect(project.pages).toHaveLength(1); + }); + it('validates explicit deletion before resolving any media', async () => { const project = sampleProject.createBlankProject(); const resolveMedia = vi.fn(() => Promise.reject(new Error('must not run')));