diff --git a/studio/docs/specs/react-studio-preview/plan.md b/studio/docs/specs/react-studio-preview/plan.md index 375a8f1b15..219bb9f345 100644 --- a/studio/docs/specs/react-studio-preview/plan.md +++ b/studio/docs/specs/react-studio-preview/plan.md @@ -193,3 +193,22 @@ Preview descriptions/tags, notices, all three frameworks and embedded assets; an actual 3.10.0 pack produces no React packages. Existing publication-selection and workflow-gate checks still pass. The try guide and release preparation notes are under Studio docs. This prepares packages without publishing them. + +## BPMN interchange + +Workflow definitions now imports BPMN through the existing server interchange API, +shows Info/Degraded/Dropped findings and requires process selection for multi-process +documents. Structured capability and expression-condition refusals retain server +messages and offending IDs. Row actions export after a binding/expression warning, +with absent/unknown-version/stale-source refusals. Browser tests cover the flow, +cancellation and download/reimport. A disposable real Core check verifies process +and flow preservation, stable canonical XML after reimport, structured condition +refusal and cleanup. Server export can normalize XML and add missing layout, so +original byte equality is not asserted. Editable BPMN canvas work remains #8929. + +BPMN review also verifies the native `Elsa.BpmnProcess` route. It opens a dedicated +read-only view outside the generic designer, preventing normalization and accidental +autosave before #8929 provides a native canvas. An actual Core-imported process +retains its exact root after opening and advancing beyond the autosave interval, +and still exports. Browser cases abort pending analysis/export on cancellation +and reject late navigation/download effects. diff --git a/studio/react/README.md b/studio/react/README.md index fc6c1e8ea5..aa50b2447e 100644 --- a/studio/react/README.md +++ b/studio/react/README.md @@ -68,6 +68,7 @@ New to the project? Start with the [Getting started guide](docs/getting-started. | [Configuration](docs/configuration.md) | **Adding servers**, authentication providers, feature flags, theming, environment variables. | | [Modules](docs/modules.md) | What every module does, how to enable it, and how to build a new one. | | [Module authoring](docs/module-authoring.md) | Generate, package and verify an independent extension. | +| [BPMN interchange](docs/bpmn.md) | Import findings/refusals and export imported workflows. | | [Parity audit](docs/parity.md) | Verified editor behavior, evidence boundaries and linked Preview gaps. | | [Activity settings](docs/activity-settings.md) | Commit, log persistence, resilience, task and version settings; instance retry history. | | [Designer](docs/designer.md) | Containers, the quick-add palette, data flow, call context, keyboard shortcuts. | diff --git a/studio/react/docs/bpmn.md b/studio/react/docs/bpmn.md new file mode 100644 index 0000000000..119df8d317 --- /dev/null +++ b/studio/react/docs/bpmn.md @@ -0,0 +1,48 @@ +# BPMN import and export + +From Workflow definitions, choose **Import BPMN**, select a `.bpmn` file up to +10 MB, and review the server's Info, Degraded and Dropped findings. Element and +process IDs identify affected parts. Dropped elements will not run; Degraded +elements behave differently from their authored definition. A document with more +than one process requires an explicit process selection before **Import** enables. +Canceling the dialog persists nothing. Confirming imports the same selected file +and opens a read-only view of the new draft. This view preserves the native +BPMN root and never normalizes or autosaves it. Export remains available; canvas +editing is separate work in #8929. + +The API remains the source of truth for supported BPMN capabilities. Capability +refusals list missing capabilities and offending elements. Unsupported expression +conditions list sequence-flow IDs and explain why unconditional execution would +change routing. These messages use stable server error codes and retain the +server's explanation. Unknown codes and older-server errors show the ordinary +server error rather than guessing their meaning. + +Use a definition row's **Actions → Export BPMN** to download the latest imported +document. The confirmation warns that bindings and expressions are included. +Export is refused when BPMN source is absent, its recorded version is unknown, +or it has become stale after workflow edits. The browser downloads the server's +XML bytes; the server may normalize XML and add missing diagram layout. Reimport +preserves the process/flow model, and a subsequent export is stable. It does not +promise byte equality with an original document that omitted layout. + +Import requires `workflows/definitions:write`, export/analyze require `:view`, +and the backend must have BPMN interchange enabled. Existing read-only server +configuration hides the import action. Requests share the configured API origin, +authentication and tenant headers; closing a dialog cancels its outstanding request. + +The public API client exposes `client.bpmn.analyze(file, signal)`, +`client.bpmn.import(file, options, signal)` and +`client.bpmn.export(definitionId, signal)`, with typed analysis/results and optional +process/name/definition IDs for API consumers. The UI imports new drafts, so it +does not overwrite an existing definition. + +Browser tests cover findings, process choice, cancellation of pending analysis and +export requests, native-root preservation without autosave, capability/condition +refusals, stale/missing-source refusals, unknown error fallback and download/reimport. +To check a disposable real backend, set `ELSA_TEST_API_URL`, `ELSA_TEST_USERNAME` +and `ELSA_TEST_PASSWORD`, then run `pnpm test:bpmn`. This checks analysis, +import/export/reimport, stable canonical XML, structured expression-condition +refusal and cleanup of its temporary definitions. + +This implements interchange for [#8853](https://github.com/elsa-workflows/elsa-core/issues/8853). +An editable BPMN diagram canvas is separate work in #8929. diff --git a/studio/react/dotnet/modules/Elsa.ReactStudio.WorkflowDefinitions/Client/components/bpmn-definition-view.tsx b/studio/react/dotnet/modules/Elsa.ReactStudio.WorkflowDefinitions/Client/components/bpmn-definition-view.tsx new file mode 100644 index 0000000000..14c6813cdb --- /dev/null +++ b/studio/react/dotnet/modules/Elsa.ReactStudio.WorkflowDefinitions/Client/components/bpmn-definition-view.tsx @@ -0,0 +1,21 @@ +import { useState } from "react"; +import Link from "@elsa-workflows/react-studio-sdk/navigation/link"; +import type { WorkflowDefinition } from "@elsa-workflows/react-studio-api-client/types/workflow-definitions"; +import { Button } from "@elsa-workflows/react-studio-ui/button"; +import { PageHeader } from "@elsa-workflows/react-studio-ui/common/page-header"; +import { JsonViewer } from "@elsa-workflows/react-studio-ui/common/json-viewer"; +import { BpmnExportDialog } from "./bpmn-dialogs"; + +/** The generic designer normalizes unknown roots. Keep BPMN outside that workspace + * until the dedicated canvas in #8929 can edit its native process model. */ +export function BpmnDefinitionView({ definition }: { definition: WorkflowDefinition }) { + const [exporting, setExporting] = useState(false); + return
+ Workflow definitions{definition.isLatest && }} /> +

Version {definition.version}

+ + {exporting && setExporting(false)} />} +
; +} diff --git a/studio/react/dotnet/modules/Elsa.ReactStudio.WorkflowDefinitions/Client/components/bpmn-dialogs.tsx b/studio/react/dotnet/modules/Elsa.ReactStudio.WorkflowDefinitions/Client/components/bpmn-dialogs.tsx new file mode 100644 index 0000000000..85dac63af0 --- /dev/null +++ b/studio/react/dotnet/modules/Elsa.ReactStudio.WorkflowDefinitions/Client/components/bpmn-dialogs.tsx @@ -0,0 +1,95 @@ +import { useEffect, useRef, useState } from "react"; +import { useElsaClient } from "@elsa-workflows/react-studio-api-client/react"; +import { ApiError } from "@elsa-workflows/react-studio-api-client/http"; +import type { BpmnImportAnalysis, BpmnImportResult } from "@elsa-workflows/react-studio-api-client/types/bpmn"; +import { Button } from "@elsa-workflows/react-studio-ui/button"; +import { Input } from "@elsa-workflows/react-studio-ui/input"; +import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@elsa-workflows/react-studio-ui/dialog"; +import { ErrorAlert } from "@elsa-workflows/react-studio-ui/common/error-alert"; +import { downloadBlob } from "@elsa-workflows/react-studio-sdk/utils/download"; + +/** Stable server codes select explanations; unknown envelopes retain the server message. */ +function Refusal({ error }: { error: unknown }) { + const raw = error instanceof ApiError ? error.problem.raw : undefined; + const envelope = raw && typeof raw === "object" ? raw as Record : {}; + const data = envelope.data && typeof envelope.data === "object" ? envelope.data as Record : {}; + const strings = (value: unknown) => Array.isArray(value) ? value.filter((item): item is string => typeof item === "string") : []; + const capabilities = strings(data.capabilities); + const elements = strings(data.elementIds); + const messages: Record = { + "bpmn.import.capability-unsupported": "This deployment does not support every BPMN capability the document requires. Import was refused.", + "bpmn.import.condition-unsupported": "These sequence flows carry expression conditions that Elsa does not evaluate. Import was refused to prevent unconditional routing. Remove the conditions or use conditionOutcome.", + "bpmn.export.not-imported": "This workflow has no recorded BPMN source to export.", + "bpmn.export.source-version-unknown": "The recorded BPMN source version is unknown, so a current export cannot be verified.", + "bpmn.export.source-stale": "The workflow changed after import. Export was refused because the recorded BPMN source is stale.", + }; + const message = typeof envelope.code === "string" ? messages[envelope.code] : undefined; + return <> + {message &&

{message}

} + {!!capabilities.length &&

Missing capabilities

    {capabilities.map(item =>
  • {item}
  • )}
} + {!!elements.length &&

{envelope.code === "bpmn.import.condition-unsupported" ? "Conditional sequence flows" : "Offending elements"}

    {elements.map(item =>
  • {item}
  • )}
} + + ; +} + +function useRequest() { + const request = useRef(undefined); + useEffect(() => () => request.current?.abort(), []); + return () => { request.current?.abort(); return (request.current = new AbortController()).signal; }; +} + +/** Mounted only while open: closing cancels work and discards the selected document. */ +export function BpmnImportDialog({ onClose, onImported }: { onClose: () => void; onImported: (result: BpmnImportResult) => void }) { + const client = useElsaClient(); + const begin = useRequest(); + const [file, setFile] = useState(); + const [analysis, setAnalysis] = useState(); + const [processId, setProcessId] = useState(""); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(); + const analyze = async (document: File | undefined) => { + setAnalysis(undefined); setError(undefined); setFile(undefined); setProcessId(""); + if (!document) return; + if (!document.name.toLowerCase().endsWith(".bpmn") || document.size > 10 * 1024 * 1024) { setError(new Error("Select a .bpmn document no larger than 10 MB.")); return; } + setBusy(true); + try { + const result = await client.bpmn.analyze(document, begin()); + setFile(document); setAnalysis(result); setProcessId(result.processIds.length === 1 ? result.processIds[0] : ""); + } catch (failure) { setError(failure); } finally { setBusy(false); } + }; + const submit = async () => { + if (!file || !analysis || (analysis.processIds.length > 1 && !processId)) return; + setBusy(true); setError(undefined); + try { onImported(await client.bpmn.import(file, { processId: processId || undefined }, begin())); } + catch (failure) { setError(failure); } finally { setBusy(false); } + }; + return { if (!open) onClose(); }}> + Import BPMNReview findings before importing. Dropped elements will not run; Degraded elements do not run exactly as authored. + + void analyze(event.target.files?.[0])} /> + {busy &&

Processing BPMN document…

} + + {analysis && <> + {analysis.processIds.length > 1 && } + {analysis.issues.length === 0 ?

No findings. This document reads without loss.

: [...new Set(analysis.issues.map(issue => issue.severity))].map(severity =>

{severity}

    {analysis.issues.filter(issue => issue.severity === severity).map((issue, index) =>
  • {issue.elementId && [{issue.elementId}] }{issue.message}{issue.processId && (process {issue.processId})}
  • )}
)} + } + +
; +} + +export function BpmnExportDialog({ definitionId, onClose }: { definitionId: string; onClose: () => void }) { + const client = useElsaClient(); + const begin = useRequest(); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(); + const submit = async () => { + setBusy(true); setError(undefined); + try { downloadBlob(await client.bpmn.export(definitionId, begin()), `${definitionId}.bpmn`); onClose(); } + catch (failure) { setError(failure); } finally { setBusy(false); } + }; + return { if (!open) onClose(); }}> + Export BPMNThe exported file contains this workflow's binding configuration and expressions. Only current, unchanged imported BPMN source can be exported. + + + ; +} diff --git a/studio/react/dotnet/modules/Elsa.ReactStudio.WorkflowDefinitions/Client/pages/editor-page.tsx b/studio/react/dotnet/modules/Elsa.ReactStudio.WorkflowDefinitions/Client/pages/editor-page.tsx index b07e6be163..9dde4df9b6 100644 --- a/studio/react/dotnet/modules/Elsa.ReactStudio.WorkflowDefinitions/Client/pages/editor-page.tsx +++ b/studio/react/dotnet/modules/Elsa.ReactStudio.WorkflowDefinitions/Client/pages/editor-page.tsx @@ -21,6 +21,7 @@ import { useUrlState } from "@elsa-workflows/react-studio-sdk/utils/url-state"; import { Network } from "lucide-react"; import { ActivityToolbox, childActivities, createDesignerStore, DesignerProvider, DesignerSurface, emptyFlowchart, indexDataFlow, isDesignableContainer, useActivityRegistry } from "@elsa-workflows/react-studio-sdk/designer/index"; import { CodeView } from "../components/code-view"; +import { BpmnDefinitionView } from "../components/bpmn-definition-view"; import { EditorHeader, type EditorViewState } from "../components/editor-header"; import { EditorSidePane } from "../components/editor-side-pane"; import { RunWorkflowDialog } from "../components/run-workflow-dialog"; @@ -48,7 +49,9 @@ export function WorkflowDefinitionEditorPage({ params }: { params: RouteParams } const fromId = url.get(FROM_PARAM); const { registry, isLoading: registryLoading, error: registryError } = useActivityRegistry(); - if (definition.isLoading || registryLoading) return ; + if (definition.isLoading) return ; + if (!definition.error && definition.data?.root?.type === "Elsa.BpmnProcess") return ; + if (registryLoading) return ; if (definition.error || registryError) return ; if (!definition.data) return null; diff --git a/studio/react/dotnet/modules/Elsa.ReactStudio.WorkflowDefinitions/Client/pages/list-page.tsx b/studio/react/dotnet/modules/Elsa.ReactStudio.WorkflowDefinitions/Client/pages/list-page.tsx index cf72c0df0e..c744db8176 100644 --- a/studio/react/dotnet/modules/Elsa.ReactStudio.WorkflowDefinitions/Client/pages/list-page.tsx +++ b/studio/react/dotnet/modules/Elsa.ReactStudio.WorkflowDefinitions/Client/pages/list-page.tsx @@ -31,6 +31,7 @@ import { Resources, usePermissions, Verbs } from "@elsa-workflows/react-studio-a import { downloadBlob, pickFiles } from "@elsa-workflows/react-studio-sdk/utils/download"; import { useUrlState } from "@elsa-workflows/react-studio-sdk/utils/url-state"; import { useElsaClient } from "@elsa-workflows/react-studio-api-client/react"; +import { BpmnExportDialog, BpmnImportDialog } from "../components/bpmn-dialogs"; import { CreateWorkflowDialog } from "../components/create-workflow-dialog"; import { RunWorkflowDialog } from "../components/run-workflow-dialog"; import { useWorkflowDefinitionMutations, useWorkflowDefinitions } from "../hooks"; @@ -56,6 +57,8 @@ export function WorkflowDefinitionsListPage() { const [selected, setSelected] = useState>(new Set()); const [createOpen, setCreateOpen] = useState(() => url.get("create") === "1"); + const [bpmnImportOpen, setBpmnImportOpen] = useState(false); + const [bpmnExportId, setBpmnExportId] = useState(); const [runTarget, setRunTarget] = useState(null); const latest = useWorkflowDefinitions({ @@ -222,6 +225,7 @@ export function WorkflowDefinitionsListPage() { void exportDefinition(d)}> Export + setBpmnExportId(d.definitionId)}> Export BPMN {!listReadOnly && (canPublish || canRetract) && } {!listReadOnly && canPublish && !d.isPublished && ( void run("Workflow published", () => mutations.publish.mutateAsync(d.definitionId))}> @@ -280,6 +284,7 @@ export function WorkflowDefinitionsListPage() { )} + {permissions.has(Resources.workflowDefinitions, Verbs.write) && !listReadOnly && } {canCreate && !listReadOnly && (
diff --git a/studio/react/package.json b/studio/react/package.json index a398bafffb..ae1c08566b 100644 --- a/studio/react/package.json +++ b/studio/react/package.json @@ -16,6 +16,7 @@ "docs:api": "pnpm build:packages && typedoc", "test:packages": "pnpm build:packages && node scripts/test-packed-packages.mjs", "test:module": "pnpm build:packages && node scripts/test-module-template.mjs", + "test:bpmn": "pnpm build:packages && node scripts/test-bpmn-roundtrip.mjs", "check:boundaries": "node scripts/check-module-boundaries.mjs" }, "dependencies": { diff --git a/studio/react/packages/api-client/README.md b/studio/react/packages/api-client/README.md index 4e68e8b0e7..fb2b733d1c 100644 --- a/studio/react/packages/api-client/README.md +++ b/studio/react/packages/api-client/README.md @@ -33,3 +33,11 @@ returns paged retry records; the ID is an activity execution record ID, not a gr activity ID. Both methods share the HTTP client's current authentication and tenant. `CommitStrategyDescriptor` models the commit catalogs' `name` field; other strategy catalogs retain their `typeName` field. + +## BPMN interchange + +`client.bpmn.analyze(file, signal)` returns process IDs, element counts and typed +findings. `client.bpmn.import(file, { processId, name, definitionId }, signal)` +uses multipart upload; every option is optional. `client.bpmn.export(id, signal)` +returns an XML Blob. Requests use the shared auth, tenant and cancellation behavior. +`ApiError.problem.raw` preserves the server's stable refusal code/data envelope. diff --git a/studio/react/packages/api-client/src/client.ts b/studio/react/packages/api-client/src/client.ts index 1d74b17062..b1b81ce656 100644 --- a/studio/react/packages/api-client/src/client.ts +++ b/studio/react/packages/api-client/src/client.ts @@ -1,4 +1,5 @@ import { descriptorsApi } from "./endpoints/descriptors.js"; +import { bpmnApi } from "./endpoints/bpmn.js"; import { identityApi } from "./endpoints/identity.js"; import { resilienceApi } from "./endpoints/resilience.js"; import { workflowDefinitionsApi } from "./endpoints/workflow-definitions.js"; @@ -18,6 +19,7 @@ export function createElsaClient(options: HttpClientOptions) { descriptors: descriptorsApi(http), identity: identityApi(http), resilience: resilienceApi(http), + bpmn: bpmnApi(http), }; } diff --git a/studio/react/packages/api-client/src/endpoints/bpmn.test.ts b/studio/react/packages/api-client/src/endpoints/bpmn.test.ts new file mode 100644 index 0000000000..ae573ed50d --- /dev/null +++ b/studio/react/packages/api-client/src/endpoints/bpmn.test.ts @@ -0,0 +1,34 @@ +import { afterEach, expect, it, vi } from "vitest"; +import { createElsaClient } from "../client.js"; +import { ApiError } from "../http.js"; + +afterEach(() => vi.unstubAllGlobals()); + +it("uploads the original BPMN bytes with process selection and shares auth/tenant headers", async () => { + const file = new File([""], "sample.bpmn"); + const fetch = vi.fn(async (_url: string, request: RequestInit) => { + expect(request.headers).toMatchObject({ Authorization: "Bearer test", "X-Tenant-Id": "tenant" }); + expect(request.headers).not.toHaveProperty("Content-Type"); + const form = request.body as FormData; + expect(await (form.get("file") as File).text()).toBe(await file.text()); + expect(form.get("ProcessId")).toBe("chosen"); + expect(form.get("DefinitionId")).toBe("existing"); + return Response.json({ id: "version", definitionId: "existing", version: 2, analysis: { processIds: [], issues: [], elementCounts: {} } }); + }); + vi.stubGlobal("fetch", fetch); + const client = createElsaClient({ baseUrl: "https://example.test/api", tenantId: "tenant", getAuthHeaders: () => ({ Authorization: "Bearer test" }) }); + expect((await client.bpmn.import(file, { processId: "chosen", definitionId: "existing" })).version).toBe(2); + expect(fetch.mock.calls[0][0]).toBe("https://example.test/api/bpmn/import"); +}); + +it("exports XML unchanged with an encoded definition ID and retains structured refusal data", async () => { + const xml = ''; + const refusal = { code: "bpmn.export.source-stale", message: "Changed workflow", data: { elementIds: ["Task1"] } }; + const fetch = vi.fn().mockResolvedValueOnce(new Response(xml, { headers: { "Content-Type": "application/xml" } })).mockResolvedValueOnce(Response.json(refusal, { status: 422 })); + vi.stubGlobal("fetch", fetch); + const client = createElsaClient({ baseUrl: "https://example.test/api", getAuthHeaders: () => ({}) }); + expect(await (await client.bpmn.export("definition/one")).text()).toBe(xml); + expect(fetch.mock.calls[0][0]).toBe("https://example.test/api/bpmn/definitions/definition%2Fone/export"); + try { await client.bpmn.export("definition/one"); expect.fail("Export should refuse stale source"); } + catch (error) { expect(error).toBeInstanceOf(ApiError); expect((error as ApiError).problem.raw).toEqual(refusal); } +}); diff --git a/studio/react/packages/api-client/src/endpoints/bpmn.ts b/studio/react/packages/api-client/src/endpoints/bpmn.ts new file mode 100644 index 0000000000..d572837984 --- /dev/null +++ b/studio/react/packages/api-client/src/endpoints/bpmn.ts @@ -0,0 +1,18 @@ +import type { HttpClient } from "../http.js"; +import type { BpmnImportAnalysis, BpmnImportOptions, BpmnImportResult } from "../types/bpmn.js"; + +function upload(file: File, options: BpmnImportOptions = {}) { + const form = new FormData(); + form.append("file", file, file.name); + for (const [key, value] of Object.entries(options)) if (value !== undefined) form.append(key[0].toUpperCase() + key.slice(1), value); + return form; +} + +/** Server-side interchange; importing never interprets executable XML in the browser. */ +export function bpmnApi(http: HttpClient) { + return { + analyze: (file: File, signal?: AbortSignal) => http.post("bpmn/analyze", upload(file), { signal }), + import: (file: File, options: BpmnImportOptions = {}, signal?: AbortSignal) => http.post("bpmn/import", upload(file, options), { signal }), + export: (definitionId: string, signal?: AbortSignal) => http.get(`bpmn/definitions/${encodeURIComponent(definitionId)}/export`, { parse: "blob", signal }), + }; +} diff --git a/studio/react/packages/api-client/src/index.ts b/studio/react/packages/api-client/src/index.ts index 0c7e23b332..4e25fbf1ad 100644 --- a/studio/react/packages/api-client/src/index.ts +++ b/studio/react/packages/api-client/src/index.ts @@ -8,3 +8,4 @@ export type * from "./types/descriptors.js"; export type * from "./types/workflow-definitions.js"; export type * from "./types/workflow-instances.js"; export type * from "./types/resilience.js"; +export type * from "./types/bpmn.js"; diff --git a/studio/react/packages/api-client/src/types/bpmn.ts b/studio/react/packages/api-client/src/types/bpmn.ts new file mode 100644 index 0000000000..fe89319381 --- /dev/null +++ b/studio/react/packages/api-client/src/types/bpmn.ts @@ -0,0 +1,26 @@ +/** Findings returned by the server's loss-aware BPMN reader. */ +export interface BpmnImportIssue { + severity: "Info" | "Degraded" | "Dropped" | (string & {}); + message: string; + elementId?: string; + processId?: string; +} + +export interface BpmnImportAnalysis { + processIds: string[]; + elementCounts: Record; + issues: BpmnImportIssue[]; +} + +export interface BpmnImportResult { + id: string; + definitionId: string; + version: number; + analysis: BpmnImportAnalysis; +} + +export interface BpmnImportOptions { + definitionId?: string; + name?: string; + processId?: string; +} diff --git a/studio/react/scripts/test-bpmn-roundtrip.mjs b/studio/react/scripts/test-bpmn-roundtrip.mjs new file mode 100644 index 0000000000..56a6323ef4 --- /dev/null +++ b/studio/react/scripts/test-bpmn-roundtrip.mjs @@ -0,0 +1,51 @@ +import assert from 'node:assert/strict'; +import { createElsaClient, ApiError } from '../packages/api-client/dist/index.js'; +const { ELSA_TEST_API_URL: baseUrl, ELSA_TEST_USERNAME: username, ELSA_TEST_PASSWORD: password } = process.env; +if (!baseUrl || !username || !password) throw new Error('Set ELSA_TEST_API_URL, ELSA_TEST_USERNAME and ELSA_TEST_PASSWORD for a disposable development server.'); +const response = await fetch(`${baseUrl}/identity/login`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username, password }) }); +if (!response.ok) throw new Error(`Development server login failed: ${response.status}`); +const login = await response.json(); +assert(login.isAuthenticated && login.accessToken, 'Development server authentication failed'); +const client = createElsaClient({ baseUrl, getAuthHeaders: () => ({ Authorization: `Bearer ${login.accessToken}` }) }); +const xml = ` + + + Flow + Flow + + +`; +const cleanup = []; +let primaryError; +try { + const file = new File([xml], 'preview.bpmn', { type: 'application/xml' }); + const analysis = await client.bpmn.analyze(file); + assert.deepEqual(analysis.processIds, ['preview-roundtrip']); + const first = await client.bpmn.import(file, { name: `BpmnRoundtrip${Date.now()}` }); + cleanup.push(first.definitionId); + const exported = await (await client.bpmn.export(first.definitionId)).text(); + const exportedAnalysis = await client.bpmn.analyze(new File([exported], 'exported.bpmn')); + assert.deepEqual(exportedAnalysis.processIds, analysis.processIds); + for (const element of ['startEvent', 'endEvent', 'sequenceFlow']) assert.equal(exportedAnalysis.elementCounts[element], analysis.elementCounts[element]); + const second = await client.bpmn.import(new File([exported], 'exported.bpmn'), { name: `BpmnReimport${Date.now()}` }); + cleanup.push(second.definitionId); + assert.equal(await (await client.bpmn.export(second.definitionId)).text(), exported); + const condition = xml.replace('sourceRef="Start" targetRef="End"/>', 'sourceRef="Start" targetRef="End">value > 0'); + await assert.rejects((async () => { + const unexpected = await client.bpmn.import(new File([condition], 'conditional.bpmn')); + cleanup.push(unexpected.definitionId); + })(), error => { + assert(error instanceof ApiError); + assert.equal(error.problem.raw.code, 'bpmn.import.condition-unsupported'); + assert.deepEqual(error.problem.raw.data.elementIds, ['Flow']); + return true; + }); + console.log('PASS: actual Core API analyzes, imports, exports canonical XML without changing process/flow counts, reimports and exports identically, and refuses expression conditions with structured flow IDs'); +} catch (error) { + primaryError = error; + throw error; +} finally { + const results = await Promise.allSettled(cleanup.reverse().map(id => client.workflowDefinitions.delete(id))); + const failures = results.filter(result => result.status === 'rejected').map(result => result.reason); + if (failures.length) throw new AggregateError([...(primaryError ? [primaryError] : []), ...failures], 'BPMN check cleanup failed', { cause: primaryError }); +} diff --git a/studio/react/tests/bpmn.spec.ts b/studio/react/tests/bpmn.spec.ts new file mode 100644 index 0000000000..a00335f6c1 --- /dev/null +++ b/studio/react/tests/bpmn.spec.ts @@ -0,0 +1,150 @@ +import { readFile } from "node:fs/promises"; +import { expect, test, type Page, type Route } from "@playwright/test"; + +const xml = ''; +const definition = { id: "version", definitionId: "imported", name: "Imported workflow", version: 1, isLatest: true, isPublished: false, createdAt: "2026-10-21T00:00:00Z", variables: [], inputs: [], outputs: [], outcomes: [], options: {}, customProperties: {}, root: { id: "root", type: "Elsa.BpmnProcess", version: 1, definition: { processId: "first", elements: [] }, activities: [] } }; + +async function fixture(page: Page, refusal?: { operation: "import" | "export"; code: string; data?: object }, findings = false) { + const uploads: string[] = []; + const writes: unknown[] = []; + await page.route("**/elsa/api/**", async route => { + const request = route.request(); + const path = new URL(request.url()).pathname; + if (path.endsWith("bpmn/analyze")) { + await route.fulfill({ json: { processIds: findings ? ["first", "second"] : ["first"], elementCounts: { process: 1 }, issues: findings ? [ + { severity: "Info", elementId: "Event1", message: "Information retained" }, + { severity: "Degraded", elementId: "Task1", message: "Behavior degraded" }, + { severity: "Dropped", elementId: "Lane1", message: "Lane was dropped" }, + ] : [] } }); return; + } + if (path.endsWith("bpmn/import") || path.endsWith("/export")) { + const operation = path.endsWith("bpmn/import") ? "import" : "export"; + if (operation === "import") uploads.push(request.postDataBuffer()!.toString()); + if (refusal?.operation === operation) { await route.fulfill({ status: 422, json: { message: "Server-specific explanation", code: refusal.code, data: refusal.data } }); return; } + await route.fulfill(operation === "export" ? { contentType: "application/xml", body: xml } : { json: { id: definition.id, definitionId: definition.definitionId, version: 1, analysis: {} } }); return; + } + let json: unknown = { items: [], totalCount: 0 }; + if (path.endsWith("workflow-definitions") && request.method() === "POST") writes.push(request.postDataJSON()); + if (path.endsWith("workflow-definitions")) json = { items: [definition], totalCount: 1 }; + if (path.includes("workflow-definitions/by-definition-id/")) json = definition; + await route.fulfill({ json }); + }); + await page.goto("/studio-preview/workflows/definitions"); + await expect(page.getByRole("link", { name: "Imported workflow", exact: true })).toBeVisible(); + return { uploads, writes }; +} + +async function openImport(page: Page, content = xml) { + await page.getByRole("button", { name: "Import BPMN", exact: true }).click(); + const dialog = page.getByRole("dialog", { name: "Import BPMN", exact: true }); + await dialog.getByLabel("BPMN document (up to 10 MB)").setInputFiles({ name: "sample.bpmn", mimeType: "application/xml", buffer: Buffer.from(content) }); + return dialog; +} + +test("findings require process selection, cancellation persists nothing, and import uses the selected original file", async ({ page }) => { + const { uploads } = await fixture(page, undefined, true); + let dialog = await openImport(page); + for (const severity of ["Info", "Degraded", "Dropped"]) await expect(dialog.getByRole("heading", { name: severity, exact: true })).toBeVisible(); + await expect(dialog.getByText("Lane was dropped", { exact: false })).toBeVisible(); + await expect(dialog.getByRole("button", { name: "Import", exact: true })).toBeDisabled(); + await dialog.getByRole("button", { name: "Cancel", exact: true }).click(); + expect(uploads).toEqual([]); + dialog = await openImport(page); + await dialog.getByLabel("Process", { exact: true }).selectOption("second"); + await dialog.getByRole("button", { name: "Import", exact: true }).click(); + await expect(page).toHaveURL(/definitions\/imported\/edit$/); + expect(uploads).toHaveLength(1); + expect(uploads[0]).toContain(xml); + expect(uploads[0]).toMatch(/name="ProcessId"\r\n\r\nsecond/); +}); + +for (const refusal of [ + { operation: "import" as const, code: "bpmn.import.capability-unsupported", data: { capabilities: ["ServiceTasks"], elementIds: ["Task42"] }, text: "ServiceTasks" }, + { operation: "import" as const, code: "bpmn.import.condition-unsupported", data: { elementIds: ["Flow42"] }, text: "Flow42" }, + { operation: "export" as const, code: "bpmn.export.source-stale", text: "The workflow changed after import." }, + { operation: "export" as const, code: "bpmn.export.not-imported", text: "This workflow has no recorded BPMN source to export." }, + { operation: "export" as const, code: "future.unknown", text: "Server-specific explanation" }, +]) test(`BPMN refusal preserves structured and server messages: ${refusal.code}`, async ({ page }) => { + await fixture(page, refusal); + let dialog; + if (refusal.operation === "import") { + dialog = await openImport(page); + await expect(dialog.getByText("No findings. This document reads without loss.")).toBeVisible(); + await dialog.getByRole("button", { name: "Import", exact: true }).click(); + } else { + await page.getByRole("button", { name: "Actions", exact: true }).click(); + await page.getByRole("menuitem", { name: "Export BPMN", exact: true }).click(); + dialog = page.getByRole("dialog", { name: "Export BPMN", exact: true }); + await dialog.getByRole("button", { name: "Export", exact: true }).click(); + } + await expect(dialog.getByText(refusal.text, { exact: false }).first()).toBeVisible(); + await expect(dialog.getByText("Server-specific explanation", { exact: false }).first()).toBeVisible(); + await expect(dialog).toBeVisible(); +}); + +test("BPMN download retains XML bytes and can be imported again", async ({ page }) => { + const { uploads } = await fixture(page); + await page.getByRole("button", { name: "Actions", exact: true }).click(); + await page.getByRole("menuitem", { name: "Export BPMN", exact: true }).click(); + const dialog = page.getByRole("dialog", { name: "Export BPMN", exact: true }); + await expect(dialog.getByText(/binding configuration and expressions/)).toBeVisible(); + const downloaded = page.waitForEvent("download"); + await dialog.getByRole("button", { name: "Export", exact: true }).click(); + const download = await downloaded; + expect(download.suggestedFilename()).toBe("imported.bpmn"); + const exported = await readFile((await download.path())!, "utf8"); + expect(exported).toBe(xml); + const importing = await openImport(page, exported); + await expect(importing.getByRole("button", { name: "Import", exact: true })).toBeEnabled(); + await importing.getByRole("button", { name: "Import", exact: true }).click(); + await expect(page).toHaveURL(/definitions\/imported\/edit$/); + expect(uploads[0]).toContain(xml); +}); + +test("an imported BPMN root stays read-only without normalization or autosave and still exports", async ({ page }) => { + await page.clock.install(); + const { writes } = await fixture(page); + const importing = await openImport(page); + await expect(importing.getByRole("button", { name: "Import", exact: true })).toBeEnabled(); + await importing.getByRole("button", { name: "Import", exact: true }).click(); + await expect(page.getByText("Read-only BPMN definition", { exact: true })).toBeVisible(); + await expect(page.locator(".react-flow")).toHaveCount(0); + await page.clock.fastForward(10_000); + expect(writes).toEqual([]); + await page.getByRole("button", { name: "Export BPMN", exact: true }).click(); + const downloaded = page.waitForEvent("download"); + await page.getByRole("dialog", { name: "Export BPMN", exact: true }).getByRole("button", { name: "Export", exact: true }).click(); + expect((await downloaded).suggestedFilename()).toBe("imported.bpmn"); + expect(writes).toEqual([]); +}); + +for (const operation of ["analysis", "export"] as const) test(`cancel aborts pending BPMN ${operation} without later navigation or download`, async ({ page }) => { + const { uploads, writes } = await fixture(page); + let pending!: Route; + let started!: () => void; + const intercepted = new Promise(resolve => { started = resolve; }); + const path = operation === "analysis" ? "**/bpmn/analyze" : "**/bpmn/definitions/imported/export"; + await page.route(path, route => { pending = route; started(); }); + let downloads = 0; + page.on("download", () => downloads++); + const aborted = page.waitForEvent("requestfailed", request => request.url().endsWith(operation === "analysis" ? "/bpmn/analyze" : "/export")); + let dialog; + if (operation === "analysis") dialog = await openImport(page); + else { + await page.getByRole("button", { name: "Actions", exact: true }).click(); + await page.getByRole("menuitem", { name: "Export BPMN", exact: true }).click(); + dialog = page.getByRole("dialog", { name: "Export BPMN", exact: true }); + await dialog.getByRole("button", { name: "Export", exact: true }).click(); + } + await intercepted; + await dialog.getByRole("button", { name: "Cancel", exact: true }).click(); + expect((await aborted).failure()?.errorText).toContain("ERR_ABORTED"); + // An aborted browser request may reject fulfillment; either way it cannot + // deliver a late result to the disposed component. + await pending.fulfill(operation === "analysis" ? { json: { processIds: ["first"], issues: [], elementCounts: {} } } : { contentType: "application/xml", body: xml }).catch(() => {}); + await expect(dialog).toHaveCount(0); + await expect(page).toHaveURL(/workflows\/definitions$/); + expect(uploads).toEqual([]); + expect(writes).toEqual([]); + expect(downloads).toBe(0); +});