diff --git a/studio/docs/specs/react-studio-preview/parity-observations.json b/studio/docs/specs/react-studio-preview/parity-observations.json new file mode 100644 index 0000000000..40cfd19d54 --- /dev/null +++ b/studio/docs/specs/react-studio-preview/parity-observations.json @@ -0,0 +1,51 @@ +{ + "sourceHead": "5828b2e09d19abfe198098fbbac481cb734f7a13", + "workflowLogging": { + "submittedOption": "logPersistenceStrategyType", + "optionSurvivesReload": false, + "runtimeLogConfigPresent": false + }, + "httpPorts": [ + { + "type": "Elsa.FlowSendHttpRequest", + "descriptorPresent": true, + "configuredStatusCodes": [ + 200, + 201 + ], + "actualPorts": [ + "Done" + ] + }, + { + "type": "Elsa.DownloadHttpFile", + "descriptorPresent": true, + "configuredStatusCodes": [ + 200, + 201 + ], + "actualPorts": [ + "Done" + ] + } + ], + "httpEmbedded": { + "descriptorPresent": true, + "expectedSlot": "expectedStatusCodes.0.activity", + "actualSlots": [ + "unmatchedStatusCode", + "failedToConnect", + "timeout" + ] + }, + "clipboard": { + "originalNames": [ + "Task1", + "Task2" + ], + "pastedNames": [ + "Task3", + "Task3" + ] + } +} diff --git a/studio/docs/specs/react-studio-preview/plan.md b/studio/docs/specs/react-studio-preview/plan.md index 5125e3fff5..c469139f64 100644 --- a/studio/docs/specs/react-studio-preview/plan.md +++ b/studio/docs/specs/react-studio-preview/plan.md @@ -154,3 +154,14 @@ records the wire contracts, visibility and verification boundaries. Browser fixt exercise the actual production modules; a local reference-backend check verifies save/reload of each setting and a published reusable activity's paired version IDs. This does not close the broader partial-parity or release acceptance gates. + + +## Partial parity verification + +The [evidence checklist](../../../react/docs/parity.md) covers every #8851 item. +Workflow metadata/settings, variable defaults, outcomes, versions and keyboard +history/clipboard now have mounted editor browser checks, with deeper designer-store +checks for IDs, nested clones and edge preservation. Confirmed gaps are tracked in +#8940 (workflow log persistence), #8941 (variable test values), #8942 (Applications/API +keys), #8943 (HTTP ports/embedded branches) and #8944 (batch paste names). The retained +sanitized observations identify the audited source head and real API boundaries. diff --git a/studio/react/README.md b/studio/react/README.md index 6c92b81d68..7322c0d49c 100644 --- a/studio/react/README.md +++ b/studio/react/README.md @@ -67,6 +67,7 @@ New to the project? Start with the [Getting started guide](docs/getting-started. | [Getting started](docs/getting-started.md) | Install, run, connect to a server, first sign-in. | | [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. | +| [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. | | [Deployment](docs/deployment.md) | Docker image, standalone output, runtime configuration. | diff --git a/studio/react/docs/parity.md b/studio/react/docs/parity.md new file mode 100644 index 0000000000..eddf74316f --- /dev/null +++ b/studio/react/docs/parity.md @@ -0,0 +1,38 @@ +# Partial parity audit + +This is the evidence checklist for [#8851](https://github.com/elsa-workflows/elsa-core/issues/8851), +part of the 3.11 React Studio Preview. It describes the existing implementation and +confirmed gaps; it is not a declaration of complete Blazor parity. Baseline audited: +`5828b2e09d19abfe198098fbbac481cb734f7a13` on 2026-10-21. + +| Area | Evidence and result | Follow-up | +| --- | --- | --- | +| Workflow settings | Actual editor browser test edits name/description, activation, incident and commit strategies, usable-as-activity, consumer auto-update and category through the normal save flow. Log persistence currently writes a field absent from server WorkflowOptions; a real Core save/reload drops it and creates no runtime configuration. | [#8940](https://github.com/elsa-workflows/elsa-core/issues/8940) fixes workflow-level logging. | +| Variable test values | Browser test edits and saves the separate default-value field. Source comparison with Blazor confirms there is no test-value editor for `customProperties.VariableTestValues`, which is keyed by variable ID. | [#8941](https://github.com/elsa-workflows/elsa-core/issues/8941); [#7402](https://github.com/elsa-workflows/elsa-core/issues/7402) is the separate user-scoped storage enhancement. | +| Outcomes | Actual editor browser test adds/removes named workflow outcomes, saves and reloads. Activity input dynamic-outcome tests separately cover JSON encoding, fixed outcomes and preservation of existing connection names. | No gap found in this scope. | +| Version viewer/delete | Actual editor browser test opens a historical version, verifies read-only metadata, returns to latest, confirms deletion of a non-current unpublished version, and checks the version ID sent to the API and list refresh. The UI uses the same definitions/delete permission as the server endpoint. | No gap found in this scope. | +| Applications/API keys | API-client operations exist. Security registers Users and Roles only; no application/API-key route or screen exists. No live credentials were created or logged during the audit. | [#8942](https://github.com/elsa-workflows/elsa-core/issues/8942). | +| Undo/redo | Designer-store tests restore activity settings and connections, clear redo after another edit, and undo nested duplication. Actual canvas keyboard test uses undo/redo after paste and verifies the saved/reloaded graph. | No gap found in those operations. | +| Copy/paste | Store tests verify new IDs/node IDs, deep-copy isolation, internal-edge remapping, exclusion of external edges, cut/undo and nested descendant IDs. Actual canvas keyboard test verifies normal paste/save/reload. Copying Task1 and Task2 together yields two Task3 names, confirmed through the public store API. | [#8944](https://github.com/elsa-workflows/elsa-core/issues/8944) fixes batch name allocation. | +| Activity port providers | Existing Switch slot tests cover discovery, assignment, traversal and fallback. Dynamic-outcome tests verify rendered port identities. Real Core HTTP descriptors plus status codes 200/201 produce only Done flow ports; SendHttpRequest status-code child slots are absent. | [#8943](https://github.com/elsa-workflows/elsa-core/issues/8943) adds HTTP provider parity. | + +The per-activity commit/log/resilience/task/version settings and instance retries +have their own [contract and verification checklist](activity-settings.md). +BPMN, localization, tenant switching and C# completion remain separately owned epic +children. The selectable BPMN designer work in #8929 belongs to its own epic #8898 +and explicitly depends on the import/export parity work here. + +Evidence lives in `tests/parity.spec.ts`, `packages/sdk/src/designer/store.test.ts`, +the existing specialized-editor/Switch tests, and the sanitized +[reference-backend observations](../../docs/specs/react-studio-preview/parity-observations.json). +The browser tests use the production ESM modules and real ASP.NET host with +controlled API responses; they verify editing and HTTP contracts. The logging +observation uses the real Core API and removes its temporary definition. HTTP +observations use real Core descriptors with local SDK port/slot computation; no +external HTTP activity is executed. These checks do not claim that every production +feature or permission combination has been exercised. + +Run `pnpm test`, `pnpm lint`, `pnpm typecheck` and `pnpm test:browser` from +`studio/react` after building the production assets and standalone host as described +in the deployment guide. Update this checklist and its observations as the linked +follow-ups are delivered. diff --git a/studio/react/packages/sdk/src/designer/store.test.ts b/studio/react/packages/sdk/src/designer/store.test.ts new file mode 100644 index 0000000000..a1fee5b7f3 --- /dev/null +++ b/studio/react/packages/sdk/src/designer/store.test.ts @@ -0,0 +1,85 @@ +import { describe, expect, it } from "vitest"; +import type { Activity, Flowchart } from "@elsa-workflows/react-studio-api-client/types/activities"; +import { createDesignerStore } from "./store.js"; + +function graph(): Flowchart { + return { id: "root", nodeId: "root", type: "Elsa.Flowchart", version: 1, activities: [ + { id: "alpha", nodeId: "root:alpha", type: "Elsa.WriteLine", version: 1, name: "Alpha1", customProperties: { canStartWorkflow: true }, + text: { typeName: "String", expression: { type: "Literal", value: "original" } }, metadata: { designer: { position: { x: 10, y: 20 } } } }, + { id: "beta", nodeId: "root:beta", type: "Elsa.WriteLine", version: 1, name: "Beta1" }, + { id: "outside", nodeId: "root:outside", type: "Elsa.WriteLine", version: 1, name: "Outside1" }, + ], connections: [ + { source: { activity: "alpha", port: "Done" }, target: { activity: "beta", port: "In" } }, + { source: { activity: "beta", port: "Done" }, target: { activity: "outside", port: "In" } }, + ] }; +} + +function current(store: ReturnType) { return store.getState().root as Flowchart; } + +describe("designer history and clipboard parity", () => { + it("undoes and redoes activity data without changing connections and clears redo after another edit", () => { + const store = createDesignerStore(graph()); + const original = structuredClone(current(store)); + store.getState().updateActivity("alpha", activity => ({ ...activity, customProperties: { ...activity.customProperties, commitStrategyName: "CommitAlways" } })); + expect(current(store).activities[0].customProperties?.commitStrategyName).toBe("CommitAlways"); + store.getState().undo(); + expect(current(store)).toEqual(original); + store.getState().redo(); + expect(current(store).activities[0].customProperties?.commitStrategyName).toBe("CommitAlways"); + expect(current(store).connections).toEqual(original.connections); + store.getState().undo(); + store.getState().updateActivity("alpha", activity => ({ ...activity, name: "Different" })); + store.getState().redo(); + expect(current(store).activities[0].name).toBe("Different"); + expect(store.getState().future).toHaveLength(0); + }); + + it("pastes deep copies with new IDs and only connections between copied activities", () => { + const store = createDesignerStore(graph()); + store.getState().select(["alpha", "beta"]); + store.getState().copySelection(); + store.getState().paste(); + const root = current(store); + const [alpha, beta] = root.activities.slice(3); + expect(root.activities).toHaveLength(5); + expect(alpha.id).not.toBe("alpha"); + expect(beta.id).not.toBe("beta"); + expect(alpha.nodeId).toBe(`root:${alpha.id}`); + expect(alpha.customProperties?.canStartWorkflow).toBe(false); + expect(alpha.metadata?.designer?.position).toEqual({ x: 50, y: 60 }); + expect(root.connections).toHaveLength(3); + expect(root.connections[2]).toEqual({ source: { activity: alpha.id, port: "Done" }, target: { activity: beta.id, port: "In" } }); + expect(store.getState().selectedIds).toEqual([alpha.id, beta.id]); + const copiedText = alpha.text as { expression: { value: string } }; + copiedText.expression.value = "changed copy"; + expect((root.activities[0].text as typeof copiedText).expression.value).toBe("original"); + }); + + it("cuts the selection and its edges, supports undo, and retains a pasteable clipboard", () => { + const store = createDesignerStore(graph()); + const original = structuredClone(current(store)); + store.getState().select(["alpha", "beta"]); + store.getState().cut(); + expect(current(store).activities.map(activity => activity.id)).toEqual(["outside"]); + expect(current(store).connections).toEqual([]); + store.getState().undo(); + expect(current(store)).toEqual(original); + store.getState().paste(); + expect(current(store).activities).toHaveLength(5); + expect(current(store).connections).toHaveLength(3); + }); + + it("duplicates a nested activity without reusing descendant IDs", () => { + const nested: Activity = { id: "nested", type: "Elsa.Sequence", version: 1, name: "Sequence1", activities: [{ id: "inner", type: "Elsa.WriteLine", version: 1 }] }; + const initial = graph(); + initial.activities.push(nested); + const store = createDesignerStore(initial); + store.getState().duplicate("nested"); + const copy = current(store).activities.at(-1)!; + expect(copy.id).not.toBe("nested"); + expect((copy.activities as Activity[])[0].id).not.toBe("inner"); + expect((copy.activities as Activity[])[0].type).toBe("Elsa.WriteLine"); + store.getState().undo(); + expect(current(store).activities).toEqual(initial.activities); + }); +}); diff --git a/studio/react/tests/parity.spec.ts b/studio/react/tests/parity.spec.ts new file mode 100644 index 0000000000..405a2c6bae --- /dev/null +++ b/studio/react/tests/parity.spec.ts @@ -0,0 +1,120 @@ +import { expect, test, type Locator, type Page } from "@playwright/test"; +import type { WorkflowDefinition } from "../packages/api-client/src/types/workflow-definitions"; + +async function editorFixture(page: Page) { + let definition: WorkflowDefinition = { id: "version-three", definitionId: "parity", name: "Parity workflow", version: 3, isLatest: true, isPublished: false, createdAt: "2026-10-21T00:00:00Z", + variables: [{ id: "customer", name: "Customer", typeName: "String", value: "original" }], inputs: [], outputs: [], outcomes: ["Approved"], options: {}, customProperties: {}, + root: { id: "root", nodeId: "root", type: "Elsa.Flowchart", version: 1, activities: [ + { id: "alpha", nodeId: "root:alpha", type: "Elsa.WriteLine", version: 1, name: "Alpha1", metadata: { designer: { position: { x: 0, y: 0 } } } }, + { id: "beta", nodeId: "root:beta", type: "Elsa.WriteLine", version: 1, name: "Beta1", metadata: { designer: { position: { x: 280, y: 0 } } } }, + ], connections: [{ source: { activity: "alpha", port: "Done" }, target: { activity: "beta", port: "In" } }] } }; + const historical = { ...structuredClone(definition), id: "version-one", name: "Historical workflow", version: 1, isLatest: false, isPublished: true }; + historical.root.activities = [{ id: "historical", type: "Elsa.WriteLine", version: 1, name: "HistoricalOnly", metadata: { designer: { position: { x: 0, y: 0 } } } }]; + historical.root.connections = []; + let versions = [historical, { ...definition, id: "version-two", version: 2, isLatest: false }, definition]; + const deleted: string[] = []; + await page.route("**/elsa/api/**", async route => { + const request = route.request(); + const url = new URL(request.url()); + const path = url.pathname; + let json: unknown = { items: [], totalCount: 0 }; + if (path.endsWith("descriptors/activities")) json = { items: [{ typeName: "Elsa.WriteLine", name: "WriteLine", version: 1, displayName: "Write line", category: "Tests", kind: "Action", inputs: [], outputs: [], ports: [] }] }; + else if (path.endsWith("workflow-definitions/by-definition-id/parity")) json = url.searchParams.get("versionOptions") === "Latest" ? definition : versions.find(item => String(item.version) === url.searchParams.get("versionOptions")); + else if (path.endsWith("workflow-definitions/parity/versions")) json = versions; + else if (path.includes("workflow-definition-versions/") && request.method() === "DELETE") { + const id = path.split("/").at(-1)!; + deleted.push(id); + versions = versions.filter(item => item.id !== id); + await route.fulfill({ status: 204 }); + return; + } else if (path.endsWith("workflow-definitions") && request.method() === "POST") { + definition = { ...definition, ...request.postDataJSON().model }; + json = { workflowDefinition: definition }; + } else if (path.endsWith("workflow-definitions")) json = { items: [definition], totalCount: 1 }; + else if (path.endsWith("workflow-activation-strategies")) json = { items: [{ typeName: "ActivationStrategy", displayName: "Activation choice" }] }; + else if (path.endsWith("incident-strategies")) json = { items: [{ typeName: "IncidentStrategy", displayName: "Incident choice" }] }; + else if (path.endsWith("commit-strategies/workflows")) json = { items: [{ name: "CommitAlways", displayName: "Always commit" }] }; + else if (path.endsWith("descriptors/variables")) json = { items: [{ typeName: "String", displayName: "String" }] }; + else if (path.endsWith("storage-drivers")) json = { items: [{ typeName: "WorkflowStorage", displayName: "Workflow" }] }; + await route.fulfill({ json }); + }); + await page.goto("/studio-preview/workflows/definitions/parity/edit"); + await expect(page.locator(".react-flow").first()).toBeVisible(); + return { saved: () => definition, deleted }; +} + +function field(panel: Locator, label: string) { return panel.locator("label").filter({ hasText: new RegExp(`^${label}[*]?$`) }).locator(".."); } +async function choose(page: Page, panel: Locator, label: string, option: string) { + await field(panel, label).getByRole("combobox").click(); + await page.getByRole("option", { name: option, exact: true }).click(); +} + +test("workflow settings, variable defaults and outcomes reach the normal save flow", async ({ page }) => { + const fixture = await editorFixture(page); + const panel = page.getByRole("tabpanel", { name: "Properties", exact: true }); + await field(panel, "Name").getByRole("textbox").fill("Audited workflow"); + await field(panel, "Description").getByRole("textbox").fill("Parity description"); + await choose(page, panel, "Activation strategy", "Activation choice"); + await choose(page, panel, "Incident strategy", "Incident choice"); + await choose(page, panel, "Commit strategy", "Always commit"); + await field(panel, "Usable as activity").getByRole("checkbox").check(); + await field(panel, "Auto-update referencing workflows").getByRole("checkbox").check(); + await field(panel, "Activity category").getByRole("textbox").fill("Parity category"); + await expect.poll(() => fixture.saved().options).toMatchObject({ activationStrategyType: "ActivationStrategy", incidentStrategyType: "IncidentStrategy", commitStrategyName: "CommitAlways", usableAsActivity: true, autoUpdateConsumingWorkflows: true, activityCategory: "Parity category" }); + await expect.poll(() => fixture.saved().name).toBe("Audited workflow"); + await expect.poll(() => fixture.saved().description).toBe("Parity description"); + await page.getByRole("tab", { name: "Variables", exact: true }).click(); + await page.getByRole("tabpanel", { name: "Variables", exact: true }).getByRole("button", { name: "Edit", exact: true }).click(); + const dialog = page.getByRole("dialog", { name: "Edit variable", exact: true }); + await field(dialog, "Default value").getByRole("textbox").fill("Updated default"); + await dialog.getByRole("button", { name: "Save", exact: true }).click(); + await expect.poll(() => fixture.saved().variables[0].value).toBe("Updated default"); + await page.getByRole("tab", { name: "I/O", exact: true }).click(); + await page.getByPlaceholder("Outcome name").fill("Rejected"); + await page.getByPlaceholder("Outcome name").press("Enter"); + await expect.poll(() => fixture.saved().outcomes).toEqual(["Approved", "Rejected"]); + await page.getByRole("tabpanel", { name: "I/O", exact: true }).getByText("Approved", { exact: true }).getByRole("button", { name: "Remove", exact: true }).click(); + await expect.poll(() => fixture.saved().outcomes).toEqual(["Rejected"]); + await page.reload(); + await page.getByRole("tab", { name: "I/O", exact: true }).click(); + await expect(page.getByRole("tabpanel", { name: "I/O", exact: true }).getByText("Rejected", { exact: true })).toBeVisible(); +}); + +test("version viewing is read-only and deletion targets the selected draft version", async ({ page }) => { + const fixture = await editorFixture(page); + await page.getByRole("tab", { name: "Versions", exact: true }).click(); + const versions = page.getByRole("tabpanel", { name: "Versions", exact: true }); + await versions.getByRole("row").filter({ hasText: "v1" }).getByRole("button", { name: "View", exact: true }).click(); + await expect(page).toHaveURL(/version=1/); + await expect(page.locator(".react-flow").first()).toBeVisible(); + const name = field(page.getByRole("tabpanel", { name: "Properties", exact: true }), "Name").getByRole("textbox"); + await expect(name).toHaveAttribute("readonly", ""); + await expect(name).toHaveValue("Historical workflow"); + await expect(page.locator('.react-flow__node[data-id="historical"]')).toBeVisible(); + await expect(page.locator('.react-flow__node[data-id="alpha"]')).toHaveCount(0); + await page.goto("/studio-preview/workflows/definitions/parity/edit"); + await expect(name).toHaveValue("Parity workflow"); + await expect(name).not.toHaveAttribute("readonly", ""); + await expect(page.locator('.react-flow__node[data-id="alpha"]')).toBeVisible(); + await expect(page.locator('.react-flow__node[data-id="historical"]')).toHaveCount(0); + await page.getByRole("tab", { name: "Versions", exact: true }).click(); + await page.getByRole("tabpanel", { name: "Versions", exact: true }).getByRole("row").filter({ hasText: "v2" }).getByRole("button", { name: "Delete", exact: true }).click(); + await page.getByRole("alertdialog").getByRole("button", { name: "Delete", exact: true }).click(); + await expect.poll(() => fixture.deleted).toEqual(["version-two"]); + await expect(page.getByRole("tabpanel", { name: "Versions", exact: true }).getByRole("row").filter({ hasText: "v2" })).toHaveCount(0); +}); + +test("canvas keyboard copy/paste, undo and redo update the saved graph", async ({ page }) => { + const fixture = await editorFixture(page); + await page.locator('.react-flow__node[data-id="alpha"]').click(); + await page.keyboard.press("Control+c"); + await page.keyboard.press("Control+v"); + await expect(page.locator(".react-flow__node")).toHaveCount(3); + await page.keyboard.press("Control+z"); + await expect(page.locator(".react-flow__node")).toHaveCount(2); + await page.keyboard.press("Control+Shift+z"); + await expect(page.locator(".react-flow__node")).toHaveCount(3); + await expect.poll(() => (fixture.saved().root.activities as unknown[]).length).toBe(3); + await page.reload(); + await expect(page.locator(".react-flow__node")).toHaveCount(3); +});