From 9871a95a2e4484464ae16d2d7a2d2d47b9637fbe Mon Sep 17 00:00:00 2001 From: Sipke Schoorstra Date: Wed, 21 Oct 2026 01:12:10 +0000 Subject: [PATCH 1/2] Add loss-aware BPMN import and export to React Studio --- .../docs/specs/react-studio-preview/plan.md | 12 +++ studio/react/README.md | 1 + studio/react/docs/bpmn.md | 45 ++++++++ .../Client/components/bpmn-dialogs.tsx | 95 +++++++++++++++++ .../Client/pages/list-page.tsx | 7 ++ studio/react/package.json | 1 + studio/react/packages/api-client/README.md | 8 ++ .../react/packages/api-client/src/client.ts | 2 + .../api-client/src/endpoints/bpmn.test.ts | 34 ++++++ .../packages/api-client/src/endpoints/bpmn.ts | 18 ++++ studio/react/packages/api-client/src/index.ts | 1 + .../packages/api-client/src/types/bpmn.ts | 26 +++++ studio/react/scripts/test-bpmn-roundtrip.mjs | 51 +++++++++ studio/react/tests/bpmn.spec.ts | 100 ++++++++++++++++++ 14 files changed, 401 insertions(+) create mode 100644 studio/react/docs/bpmn.md create mode 100644 studio/react/dotnet/modules/Elsa.ReactStudio.WorkflowDefinitions/Client/components/bpmn-dialogs.tsx create mode 100644 studio/react/packages/api-client/src/endpoints/bpmn.test.ts create mode 100644 studio/react/packages/api-client/src/endpoints/bpmn.ts create mode 100644 studio/react/packages/api-client/src/types/bpmn.ts create mode 100644 studio/react/scripts/test-bpmn-roundtrip.mjs create mode 100644 studio/react/tests/bpmn.spec.ts diff --git a/studio/docs/specs/react-studio-preview/plan.md b/studio/docs/specs/react-studio-preview/plan.md index 375a8f1b15..b4383da3b2 100644 --- a/studio/docs/specs/react-studio-preview/plan.md +++ b/studio/docs/specs/react-studio-preview/plan.md @@ -193,3 +193,15 @@ 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. 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..76b40e0ec2 --- /dev/null +++ b/studio/react/docs/bpmn.md @@ -0,0 +1,45 @@ +# 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 the new draft in the editor. + +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, 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-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/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..156e9c35bf --- /dev/null +++ b/studio/react/tests/bpmn.spec.ts @@ -0,0 +1,100 @@ +import { readFile } from "node:fs/promises"; +import { expect, test, type Page } 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.Flowchart", version: 1, activities: [], connections: [] } }; + +async function fixture(page: Page, refusal?: { operation: "import" | "export"; code: string; data?: object }, findings = false) { + const uploads: string[] = []; + 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")) 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; +} + +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); +}); From 0e08fec9a76b7160d6fec521e097a9474aaf7df4 Mon Sep 17 00:00:00 2001 From: Sipke Schoorstra Date: Wed, 21 Oct 2026 01:34:16 +0000 Subject: [PATCH 2/2] Preserve native BPMN roots and verify pending request cancellation --- .../docs/specs/react-studio-preview/plan.md | 7 +++ studio/react/docs/bpmn.md | 7 ++- .../components/bpmn-definition-view.tsx | 21 +++++++ .../Client/pages/editor-page.tsx | 5 +- studio/react/tests/bpmn.spec.ts | 60 +++++++++++++++++-- 5 files changed, 92 insertions(+), 8 deletions(-) create mode 100644 studio/react/dotnet/modules/Elsa.ReactStudio.WorkflowDefinitions/Client/components/bpmn-definition-view.tsx diff --git a/studio/docs/specs/react-studio-preview/plan.md b/studio/docs/specs/react-studio-preview/plan.md index b4383da3b2..219bb9f345 100644 --- a/studio/docs/specs/react-studio-preview/plan.md +++ b/studio/docs/specs/react-studio-preview/plan.md @@ -205,3 +205,10 @@ cancellation and download/reimport. A disposable real Core check verifies proces 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/docs/bpmn.md b/studio/react/docs/bpmn.md index 76b40e0ec2..119df8d317 100644 --- a/studio/react/docs/bpmn.md +++ b/studio/react/docs/bpmn.md @@ -6,7 +6,9 @@ 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 the new draft in the editor. +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 @@ -34,7 +36,8 @@ The public API client exposes `client.bpmn.analyze(file, signal)`, 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, capability/condition +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, 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/pages/editor-page.tsx b/studio/react/dotnet/modules/Elsa.ReactStudio.WorkflowDefinitions/Client/pages/editor-page.tsx index 2944665433..e058d2a498 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, 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/tests/bpmn.spec.ts b/studio/react/tests/bpmn.spec.ts index 156e9c35bf..a00335f6c1 100644 --- a/studio/react/tests/bpmn.spec.ts +++ b/studio/react/tests/bpmn.spec.ts @@ -1,11 +1,12 @@ import { readFile } from "node:fs/promises"; -import { expect, test, type Page } from "@playwright/test"; +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.Flowchart", version: 1, activities: [], connections: [] } }; +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; @@ -23,13 +24,14 @@ async function fixture(page: Page, refusal?: { operation: "import" | "export"; c 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; + return { uploads, writes }; } async function openImport(page: Page, content = xml) { @@ -40,7 +42,7 @@ async function openImport(page: Page, content = xml) { } test("findings require process selection, cancellation persists nothing, and import uses the selected original file", async ({ page }) => { - const uploads = await fixture(page, undefined, true); + 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(); @@ -81,7 +83,7 @@ for (const refusal of [ }); test("BPMN download retains XML bytes and can be imported again", async ({ page }) => { - const uploads = await fixture(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 }); @@ -98,3 +100,51 @@ test("BPMN download retains XML bytes and can be imported again", async ({ page 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); +});