From 28c6bb68060c3b41ef91882e367e9ac227f58aaa Mon Sep 17 00:00:00 2001 From: Gav <30061717+Abyssalisk@users.noreply.github.com> Date: Tue, 6 Oct 2026 15:43:57 -0700 Subject: [PATCH 1/9] fix(designer): stop edge remove buttons from stacking and sticking Ported from elsa-workflows/elsa-studio@5379ea79fec28705ae888b75cf5f03993ad666f0; map src/modules/Elsa.Studio.Workflows.Designer/ClientLib to src/studio/modules/Elsa.Studio.Workflows.Designer/ClientLib. --- .../api/__tests__/edge-hover-tools.test.ts | 100 ++++++++++++++++++ .../src/designer/api/create-graph.ts | 27 +---- .../src/designer/api/edge-hover-tools.ts | 50 +++++++++ .../ClientLib/vitest.config.ts | 5 +- 4 files changed, 155 insertions(+), 27 deletions(-) create mode 100644 src/studio/modules/Elsa.Studio.Workflows.Designer/ClientLib/src/designer/api/__tests__/edge-hover-tools.test.ts create mode 100644 src/studio/modules/Elsa.Studio.Workflows.Designer/ClientLib/src/designer/api/edge-hover-tools.ts diff --git a/src/studio/modules/Elsa.Studio.Workflows.Designer/ClientLib/src/designer/api/__tests__/edge-hover-tools.test.ts b/src/studio/modules/Elsa.Studio.Workflows.Designer/ClientLib/src/designer/api/__tests__/edge-hover-tools.test.ts new file mode 100644 index 0000000000..5105d677f1 --- /dev/null +++ b/src/studio/modules/Elsa.Studio.Workflows.Designer/ClientLib/src/designer/api/__tests__/edge-hover-tools.test.ts @@ -0,0 +1,100 @@ +/** + * The remove button follows the pointer. + * + * The cases replay event sequences X6 raises when the pointer moves quickly: an edge is entered again + * before it was left, or the pointer reaches another edge, a node, the blank canvas or the outside of + * the graph without an `edge:mouseleave`. Each one used to leave a remove button behind. + */ +import {Edge} from '@antv/x6'; +import {beforeEach, describe, expect, it} from 'vitest'; +import {EdgeHoverEvents, registerEdgeHoverTools} from '../edge-hover-tools'; + +type Handler = (args: any) => void; + +class FakeGraph { + private readonly handlers = new Map(); + + readonly on = ((name: string, handler: Handler) => { + this.handlers.set(name, [...(this.handlers.get(name) ?? []), handler]); + return this; + }) as unknown as EdgeHoverEvents['on']; + + emit(name: string, args: object = {}) { + for (const handler of this.handlers.get(name) ?? []) + handler(args); + } + + listens(name: string) { + return this.handlers.has(name); + } +} + +const newEdge = () => new Edge({source: {x: 0, y: 0}, target: {x: 100, y: 0}}); +const toolNames = (edge: Edge) => (edge.getTools()?.items ?? []).map(item => typeof item === 'string' ? item : item.name); +const removeButtons = (edge: Edge) => toolNames(edge).filter(name => name === 'button-remove').length; + +describe('registerEdgeHoverTools', () => { + let graph: FakeGraph; + let first: Edge; + let second: Edge; + + beforeEach(() => { + graph = new FakeGraph(); + first = newEdge(); + second = newEdge(); + registerEdgeHoverTools(graph, true); + }); + + it('shows the vertex handles and one remove button on the hovered edge', () => { + graph.emit('edge:mouseenter', {edge: first}); + + expect(toolNames(first)).toEqual(['vertices', 'button-remove']); + }); + + it('adds each tool once when an edge is entered again before it was left', () => { + graph.emit('edge:mouseenter', {edge: first}); + graph.emit('edge:mouseenter', {edge: first}); + graph.emit('edge:mouseenter', {edge: first}); + + expect(toolNames(first)).toEqual(['vertices', 'button-remove']); + }); + + it('removes the button when the pointer leaves the edge', () => { + graph.emit('edge:mouseenter', {edge: first}); + graph.emit('edge:mouseleave', {edge: first}); + + expect(removeButtons(first)).toBe(0); + }); + + it('moves the button when the pointer reaches another edge without leaving the first', () => { + graph.emit('edge:mouseenter', {edge: first}); + graph.emit('edge:mouseenter', {edge: second}); + + expect(removeButtons(first)).toBe(0); + expect(removeButtons(second)).toBe(1); + }); + + it.each(['node:mouseenter', 'blank:mouseover', 'graph:mouseleave'])( + 'removes the button on %s when edge:mouseleave never fired', + event => { + graph.emit('edge:mouseenter', {edge: first}); + graph.emit(event); + + expect(removeButtons(first)).toBe(0); + }); + + it('forgets an edge that was removed while hovered', () => { + graph.emit('edge:mouseenter', {edge: first}); + graph.emit('edge:removed', {edge: first}); + graph.emit('edge:mouseenter', {edge: second}); + + expect(removeButtons(second)).toBe(1); + }); + + it('registers nothing when edges are not interactive', () => { + const readOnly = new FakeGraph(); + registerEdgeHoverTools(readOnly, false); + + expect(readOnly.listens('edge:mouseenter')).toBe(false); + }); +}); diff --git a/src/studio/modules/Elsa.Studio.Workflows.Designer/ClientLib/src/designer/api/create-graph.ts b/src/studio/modules/Elsa.Studio.Workflows.Designer/ClientLib/src/designer/api/create-graph.ts index 9a2452b82f..c896b1cd2c 100644 --- a/src/studio/modules/Elsa.Studio.Workflows.Designer/ClientLib/src/designer/api/create-graph.ts +++ b/src/studio/modules/Elsa.Studio.Workflows.Designer/ClientLib/src/designer/api/create-graph.ts @@ -14,6 +14,7 @@ import {getActivityMeasurementScopeClass} from "./calculate-activity-size"; import {arrangeSequenceGraph, moveSelectedSequenceNode, normalizeSequenceOrientation, withSuppressedGraphUpdated} from "./sequence-mode"; import {applyDesignerThemeVariables, X6DesignerTheme} from "./apply-graph-theme"; import {createDesignerGridOptions, resolveDesignerGridOptions} from "./grid-options"; +import {registerEdgeHoverTools} from "./edge-hover-tools"; import {ActivityShape, FlowchartEdgeShape, getDesignerModePolicy, resolveDesignerMode} from './designer-mode'; import {applyStateMachineEdgeAccessibility, applyStateMachineNodeAccessibility} from '../internal/state-machine-accessibility'; @@ -334,31 +335,7 @@ export async function createGraph(containerId: string, componentRef: DotNetCompo return false; }); - graph.on("edge:mouseenter", ({cell}) => { - if (!modePolicy.allowsInteractiveEdges) { - return false; - } - - cell.addTools([ - {name: "vertices"}, - { - name: "button-remove", - args: {distance: 20}, - }, - ]); - return false; - }); - - graph.on("edge:mouseleave", ({cell}) => { - if (!modePolicy.allowsInteractiveEdges) { - return false; - } - - if (cell.hasTool("button-remove")) { - cell.removeTool("button-remove"); - } - return false; - }); + registerEdgeHoverTools(graph, modePolicy.allowsInteractiveEdges); graph.on('node:click', async args => { const {e, node} = args; diff --git a/src/studio/modules/Elsa.Studio.Workflows.Designer/ClientLib/src/designer/api/edge-hover-tools.ts b/src/studio/modules/Elsa.Studio.Workflows.Designer/ClientLib/src/designer/api/edge-hover-tools.ts new file mode 100644 index 0000000000..47dcc88305 --- /dev/null +++ b/src/studio/modules/Elsa.Studio.Workflows.Designer/ClientLib/src/designer/api/edge-hover-tools.ts @@ -0,0 +1,50 @@ +import {Edge, Graph} from '@antv/x6'; + +/** The subset of the graph the hover tools listen to. */ +export type EdgeHoverEvents = Pick; + +const vertices = 'vertices'; +const removeButton = 'button-remove'; + +/** + * Shows the vertex handles and the remove button on the edge under the pointer. + * + * X6 appends a tool on every `addTools` call, and it does not raise `edge:mouseleave` when the pointer + * leaves an edge across one of its tools or straight onto another cell. So each tool is added only if + * the edge lacks it, and the remove button of the last hovered edge is cleared as soon as the pointer + * is seen over another edge, a node, the blank canvas, or outside the graph. + */ +export function registerEdgeHoverTools(graph: EdgeHoverEvents, enabled: boolean) { + if (!enabled) + return; + + let hovered: Edge | null = null; + + const hideRemoveButton = () => { + if (hovered?.hasTool(removeButton)) + hovered.removeTool(removeButton); + hovered = null; + }; + + graph.on('edge:mouseenter', ({edge}) => { + if (hovered !== edge) + hideRemoveButton(); + + if (!edge.hasTool(vertices)) + edge.addTools({name: vertices}); + if (!edge.hasTool(removeButton)) + edge.addTools({name: removeButton, args: {distance: 20}}); + + hovered = edge; + }); + + graph.on('edge:mouseleave', hideRemoveButton); + graph.on('node:mouseenter', hideRemoveButton); + // `blank:mousemove` fires only while a button is held; hovering empty canvas raises `blank:mouseover`. + graph.on('blank:mouseover', hideRemoveButton); + graph.on('graph:mouseleave', hideRemoveButton); + graph.on('edge:removed', ({edge}) => { + if (hovered === edge) + hovered = null; + }); +} diff --git a/src/studio/modules/Elsa.Studio.Workflows.Designer/ClientLib/vitest.config.ts b/src/studio/modules/Elsa.Studio.Workflows.Designer/ClientLib/vitest.config.ts index 1478f0f18a..9501769de2 100644 --- a/src/studio/modules/Elsa.Studio.Workflows.Designer/ClientLib/vitest.config.ts +++ b/src/studio/modules/Elsa.Studio.Workflows.Designer/ClientLib/vitest.config.ts @@ -13,8 +13,9 @@ import { defineConfig } from 'vitest/config'; // // Covered: src/bpmn, and the pure half of the X6 BPMN adapter under src/designer/bpmn -- its cell // mapping, badge policy and graph options are all plain functions precisely so that they can be -// tested here rather than only in a browser. The rest of the designer and react-designer bundles -// have no test suite; they are covered by the .NET side. +// tested here rather than only in a browser. The flowchart designer's edge hover tools are covered +// the same way, through the graph events they listen to. The rest of the designer and react-designer +// bundles have no test suite; they are covered by the .NET side. export default defineConfig({ test: { environment: 'jsdom', From 36c381e5460ca25d58eb542135772b033dd672f8 Mon Sep 17 00:00:00 2001 From: Sipke Schoorstra Date: Wed, 7 Oct 2026 23:51:44 +0200 Subject: [PATCH 2/9] fix(designer): ignore stale edge leave events Only clear the hover tool when the leaving edge is still the active edge. Cover a late leave from edge A after edge B becomes hovered. --- .../src/designer/api/__tests__/edge-hover-tools.test.ts | 9 +++++++++ .../ClientLib/src/designer/api/edge-hover-tools.ts | 6 +++++- 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/src/studio/modules/Elsa.Studio.Workflows.Designer/ClientLib/src/designer/api/__tests__/edge-hover-tools.test.ts b/src/studio/modules/Elsa.Studio.Workflows.Designer/ClientLib/src/designer/api/__tests__/edge-hover-tools.test.ts index 5105d677f1..59e3d9a624 100644 --- a/src/studio/modules/Elsa.Studio.Workflows.Designer/ClientLib/src/designer/api/__tests__/edge-hover-tools.test.ts +++ b/src/studio/modules/Elsa.Studio.Workflows.Designer/ClientLib/src/designer/api/__tests__/edge-hover-tools.test.ts @@ -74,6 +74,15 @@ describe('registerEdgeHoverTools', () => { expect(removeButtons(second)).toBe(1); }); + it('ignores a stale leave event from the previous edge', () => { + graph.emit('edge:mouseenter', {edge: first}); + graph.emit('edge:mouseenter', {edge: second}); + graph.emit('edge:mouseleave', {edge: first}); + + expect(removeButtons(first)).toBe(0); + expect(removeButtons(second)).toBe(1); + }); + it.each(['node:mouseenter', 'blank:mouseover', 'graph:mouseleave'])( 'removes the button on %s when edge:mouseleave never fired', event => { diff --git a/src/studio/modules/Elsa.Studio.Workflows.Designer/ClientLib/src/designer/api/edge-hover-tools.ts b/src/studio/modules/Elsa.Studio.Workflows.Designer/ClientLib/src/designer/api/edge-hover-tools.ts index 47dcc88305..f65fcd458f 100644 --- a/src/studio/modules/Elsa.Studio.Workflows.Designer/ClientLib/src/designer/api/edge-hover-tools.ts +++ b/src/studio/modules/Elsa.Studio.Workflows.Designer/ClientLib/src/designer/api/edge-hover-tools.ts @@ -38,7 +38,11 @@ export function registerEdgeHoverTools(graph: EdgeHoverEvents, enabled: boolean) hovered = edge; }); - graph.on('edge:mouseleave', hideRemoveButton); + graph.on('edge:mouseleave', ({edge}) => { + // A late leave from an older edge must not clear the current edge's button. + if (hovered === edge) + hideRemoveButton(); + }); graph.on('node:mouseenter', hideRemoveButton); // `blank:mousemove` fires only while a button is held; hovering empty canvas raises `blank:mouseover`. graph.on('blank:mouseover', hideRemoveButton); From 249e98d672757a7960129cd86696f21eada52b9b Mon Sep 17 00:00:00 2001 From: Sipke Schoorstra Date: Wed, 7 Oct 2026 23:58:36 +0200 Subject: [PATCH 3/9] fix(designer): skip hover tools in read-only graphs --- .../api/__tests__/edge-hover-tools.test.ts | 18 ++++++++++++++---- .../ClientLib/src/designer/api/create-graph.ts | 2 +- 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/src/studio/modules/Elsa.Studio.Workflows.Designer/ClientLib/src/designer/api/__tests__/edge-hover-tools.test.ts b/src/studio/modules/Elsa.Studio.Workflows.Designer/ClientLib/src/designer/api/__tests__/edge-hover-tools.test.ts index 59e3d9a624..cd2f3cedde 100644 --- a/src/studio/modules/Elsa.Studio.Workflows.Designer/ClientLib/src/designer/api/__tests__/edge-hover-tools.test.ts +++ b/src/studio/modules/Elsa.Studio.Workflows.Designer/ClientLib/src/designer/api/__tests__/edge-hover-tools.test.ts @@ -101,9 +101,19 @@ describe('registerEdgeHoverTools', () => { }); it('registers nothing when edges are not interactive', () => { - const readOnly = new FakeGraph(); - registerEdgeHoverTools(readOnly, false); - - expect(readOnly.listens('edge:mouseenter')).toBe(false); + const nonInteractive = new FakeGraph(); + registerEdgeHoverTools(nonInteractive, false); + + const events = [ + 'edge:mouseenter', + 'edge:mouseleave', + 'node:mouseenter', + 'blank:mouseover', + 'graph:mouseleave', + 'edge:removed', + ]; + + for (const event of events) + expect(nonInteractive.listens(event)).toBe(false); }); }); diff --git a/src/studio/modules/Elsa.Studio.Workflows.Designer/ClientLib/src/designer/api/create-graph.ts b/src/studio/modules/Elsa.Studio.Workflows.Designer/ClientLib/src/designer/api/create-graph.ts index c896b1cd2c..c29ca448ff 100644 --- a/src/studio/modules/Elsa.Studio.Workflows.Designer/ClientLib/src/designer/api/create-graph.ts +++ b/src/studio/modules/Elsa.Studio.Workflows.Designer/ClientLib/src/designer/api/create-graph.ts @@ -335,7 +335,7 @@ export async function createGraph(containerId: string, componentRef: DotNetCompo return false; }); - registerEdgeHoverTools(graph, modePolicy.allowsInteractiveEdges); + registerEdgeHoverTools(graph, !readOnly && modePolicy.allowsInteractiveEdges); graph.on('node:click', async args => { const {e, node} = args; From 925f04686738d7bbe6817c6aa3a68ae2ee8ba338 Mon Sep 17 00:00:00 2001 From: Sipke Schoorstra Date: Wed, 7 Oct 2026 23:58:46 +0200 Subject: [PATCH 4/9] test(studio): prove edge hover tools in real Chromium --- test/studio/browser/DesignerHover/.gitignore | 2 + test/studio/browser/DesignerHover/README.md | 44 ++ test/studio/browser/DesignerHover/fixture.ts | 53 ++ .../browser/DesignerHover/package-lock.json | 561 ++++++++++++++++++ .../studio/browser/DesignerHover/package.json | 13 + test/studio/browser/DesignerHover/run.mjs | 312 ++++++++++ .../browser/DesignerHover/source-binding.mjs | 47 ++ .../DesignerHover/source-binding.test.mjs | 73 +++ 8 files changed, 1105 insertions(+) create mode 100644 test/studio/browser/DesignerHover/.gitignore create mode 100644 test/studio/browser/DesignerHover/README.md create mode 100644 test/studio/browser/DesignerHover/fixture.ts create mode 100644 test/studio/browser/DesignerHover/package-lock.json create mode 100644 test/studio/browser/DesignerHover/package.json create mode 100644 test/studio/browser/DesignerHover/run.mjs create mode 100644 test/studio/browser/DesignerHover/source-binding.mjs create mode 100644 test/studio/browser/DesignerHover/source-binding.test.mjs diff --git a/test/studio/browser/DesignerHover/.gitignore b/test/studio/browser/DesignerHover/.gitignore new file mode 100644 index 0000000000..ac5a20a410 --- /dev/null +++ b/test/studio/browser/DesignerHover/.gitignore @@ -0,0 +1,2 @@ +node_modules/ +receipt.json diff --git a/test/studio/browser/DesignerHover/README.md b/test/studio/browser/DesignerHover/README.md new file mode 100644 index 0000000000..51346915b8 --- /dev/null +++ b/test/studio/browser/DesignerHover/README.md @@ -0,0 +1,44 @@ +# Designer edge-hover source browser proof + +This isolated Chromium smoke imports the production `registerEdgeHoverTools` helper from the mapped Studio ClientLib and bundles the ClientLib's restored X6. It uses native pointer movement and a real X6 remove-button click. The page exposes fresh-graph setup and observations; it does not synthesize graph events or remove edges through the model. + +This proves the helper's browser behavior at one exact source head. The ClientLib Vitest/typecheck/production build gate separately proves its integration into `createGraph`. It is not the paused #8643 paired-package/browser matrix, a full Studio host journey, or npm publication evidence. + +## Hosted commands + +Use Node 22 on the hosted runner. The existing source gate prepares the reviewed Designer lock and dependencies, runs the ten source cases/typecheck and builds the production bundles: + +```sh +scripts/integration-program/build_studio_clientlibs.sh . +npm ci --prefix test/studio/browser/DesignerHover +cd test/studio/browser/DesignerHover +npm test +npx --no-install playwright install --with-deps chromium +node run.mjs --expected-head "$EXPECTED_HEAD" --output "$RUNNER_TEMP/designer-hover-browser.json" +``` + +Set `EXPECTED_HEAD` to the exact candidate SHA selected by the hosted workflow, rather than deriving it from the checkout under test. For a browser-only diagnostic run after the source gate has already passed, reuse its installed Designer dependencies and execute the fixture commands. A standalone dependency setup must copy `scripts/integration-program/consolidated-build/studio-clientlib-lockfiles/designer.package-lock.json` to the mapped `ClientLib/package-lock.json` and run `npm ci --force` there first. The browser runner rejects a different checkout lock or restored X6 version. Do not regenerate the ClientLib lock for this proof. + +Playwright 1.61.1 and esbuild 0.28.2 are pinned from the existing paused PackageCompatibility harness lock, with only their dependency closure retained here. That checkout and its artifacts are untouched. X6 is deliberately not installed in this fixture package; bundling resolves it exclusively from the mapped ClientLib. The reviewed lock currently resolves X6 2.19.2. + +## Acceptance manifest + +The receipt has exactly nine assertion records, all required to pass: + +1. `real-x6-rendered`: two real rendered edges and matching X6 model cells. +2. `repeated-edge-hover`: five native entries on A, exactly one button and one vertex tool in both rendered DOM and model on each entry. +3. `edge-to-edge-transfer`: A's button clears and B has exactly one; A's vertex tool remains. +4. `node-clears-hover`: native node entry clears the button. +5. `blank-clears-hover`: native blank hover clears the button without a held mouse button. +6. `outside-clears-hover`: B extends to the graph boundary; moving directly along B to outside clears its button and produces a graph leave. +7. `remove-tool-entry`: pointer reaches the real button hit target along A; its button survives. +8. `remove-tool-click`: native mouse down/up removes A from the model and DOM, retaining B. +9. `noninteractive-no-tools`: a fresh disabled graph receives repeated native entries on A/B, retains both edges and creates no model or rendered tools. + +The stale-A-leave-after-B regression is a separate synthetic source unit test. This browser proof does not replay that event sequence. + +## Receipt and lifecycle + +`--expected-head` and `--output` are required; the output parent directory must already exist. Exit zero requires all nine assertions, zero browser errors and zero external browser requests. Failure records contain only fixed stage/assertion categories, never exception text, console messages, URLs, screenshots or traces. The page is served from an ephemeral loopback port and external browser requests are blocked. Browser/context/server cleanup runs in `finally`, and signal handlers close native resources. Playwright actions/assertions have finite timeouts; the hosted wrapper must also bound the complete process. + +The JSON binds the Git source commit, production helper/create-graph/unit-test/Vitest configuration, reviewed and installed ClientLib locks, ClientLib package manifest, source workflow, all fixture source files, and every actual esbuild input to SHA-256 and byte length. It records the exact installed X6/Playwright/esbuild and launched Chromium versions, the JavaScript bundle digest, and native event counts for interactive/noninteractive graphs. The runner requires the expected Git HEAD and clean tracked inputs initially, then rechecks HEAD, tracked cleanliness and every input hash after browser teardown before accepting the result. Eight pure Node guard regressions cover unchanged inputs, mismatched/missing expected head, pre/post tracked edits, restored dependency/lock changes, changed HEAD and untracked source. The receipt is written atomically with mode 0600. Do not accept merely a prior-head receipt or a command that omitted required assertions. diff --git a/test/studio/browser/DesignerHover/fixture.ts b/test/studio/browser/DesignerHover/fixture.ts new file mode 100644 index 0000000000..957e799c63 --- /dev/null +++ b/test/studio/browser/DesignerHover/fixture.ts @@ -0,0 +1,53 @@ +import {Graph} from '@antv/x6'; +import '@antv/x6/dist/index.css'; +import {registerEdgeHoverTools} from '../../../../src/studio/modules/Elsa.Studio.Workflows.Designer/ClientLib/src/designer/api/edge-hover-tools'; + +// Expose observations and fresh-graph setup only. All acceptance interactions come from Chromium's mouse. +const container = document.getElementById('graph')!; +let graph: Graph; +let events: Record; + +function reset(enabled: boolean) { + graph?.dispose(); + events = {}; + graph = new Graph({ + container, + width: 800, + height: 440, + async: false, + interacting: enabled, + background: {color: '#fafafa'}, + }); + registerEdgeHoverTools(graph, enabled); + for (const event of ['edge:mouseenter', 'node:mouseenter', 'blank:mouseover', 'graph:mouseleave'] as const) { + graph.on(event, (args: {edge?: {id: string}}) => { + const key = args.edge ? `${event}:${args.edge.id}` : event; + events[key] = (events[key] ?? 0) + 1; + }); + } + graph.addNode({id: 'node', x: 700, y: 90, width: 80, height: 60, label: 'Node'}); + graph.addEdge({id: 'edge-a', source: {x: 100, y: 120}, target: {x: 660, y: 120}}); + // B reaches the boundary, allowing a direct edge-to-outside transition without crossing blank canvas. + graph.addEdge({id: 'edge-b', source: {x: 100, y: 240}, target: {x: 800, y: 240}}); +} + +function snapshot() { + return { + events: {...events}, + edges: ['edge-a', 'edge-b'].map(id => { + const edge = graph.getCellById(id); + const items = edge?.getTools()?.items ?? []; + const names = items.map(item => typeof item === 'string' ? item : item.name); + return { + id, + exists: Boolean(edge), + buttons: names.filter(name => name === 'button-remove').length, + vertices: names.filter(name => name === 'vertices').length, + }; + }), + }; +} + +reset(true); +Object.assign(window, {hoverFixture: {reset, snapshot}}); +window.addEventListener('pagehide', () => graph.dispose(), {once: true}); diff --git a/test/studio/browser/DesignerHover/package-lock.json b/test/studio/browser/DesignerHover/package-lock.json new file mode 100644 index 0000000000..17dd47c45c --- /dev/null +++ b/test/studio/browser/DesignerHover/package-lock.json @@ -0,0 +1,561 @@ +{ + "name": "elsa-designer-hover-browser", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "elsa-designer-hover-browser", + "devDependencies": { + "@playwright/test": "1.61.1", + "esbuild": "0.28.2" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", + "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", + "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", + "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", + "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", + "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", + "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", + "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", + "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", + "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", + "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", + "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", + "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", + "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", + "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", + "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", + "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", + "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", + "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", + "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", + "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", + "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", + "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", + "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", + "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", + "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", + "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@playwright/test": { + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.61.1.tgz", + "integrity": "sha512-8nKv6+0RJSL9FE4jYOEGXnPeM/Hg12qZpmqzZjRh3qM0Y7c3z1mrOTfFLids72RDQYVh9WpLEfR5WdpNX4fkig==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.61.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/esbuild": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", + "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.2", + "@esbuild/android-arm": "0.28.2", + "@esbuild/android-arm64": "0.28.2", + "@esbuild/android-x64": "0.28.2", + "@esbuild/darwin-arm64": "0.28.2", + "@esbuild/darwin-x64": "0.28.2", + "@esbuild/freebsd-arm64": "0.28.2", + "@esbuild/freebsd-x64": "0.28.2", + "@esbuild/linux-arm": "0.28.2", + "@esbuild/linux-arm64": "0.28.2", + "@esbuild/linux-ia32": "0.28.2", + "@esbuild/linux-loong64": "0.28.2", + "@esbuild/linux-mips64el": "0.28.2", + "@esbuild/linux-ppc64": "0.28.2", + "@esbuild/linux-riscv64": "0.28.2", + "@esbuild/linux-s390x": "0.28.2", + "@esbuild/linux-x64": "0.28.2", + "@esbuild/netbsd-arm64": "0.28.2", + "@esbuild/netbsd-x64": "0.28.2", + "@esbuild/openbsd-arm64": "0.28.2", + "@esbuild/openbsd-x64": "0.28.2", + "@esbuild/openharmony-arm64": "0.28.2", + "@esbuild/sunos-x64": "0.28.2", + "@esbuild/win32-arm64": "0.28.2", + "@esbuild/win32-ia32": "0.28.2", + "@esbuild/win32-x64": "0.28.2" + } + }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/playwright": { + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.1.tgz", + "integrity": "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.61.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.1.tgz", + "integrity": "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + } + } +} diff --git a/test/studio/browser/DesignerHover/package.json b/test/studio/browser/DesignerHover/package.json new file mode 100644 index 0000000000..947b358c22 --- /dev/null +++ b/test/studio/browser/DesignerHover/package.json @@ -0,0 +1,13 @@ +{ + "name": "elsa-designer-hover-browser", + "private": true, + "type": "module", + "scripts": { + "test": "node --test source-binding.test.mjs", + "proof": "node run.mjs" + }, + "devDependencies": { + "@playwright/test": "1.61.1", + "esbuild": "0.28.2" + } +} diff --git a/test/studio/browser/DesignerHover/run.mjs b/test/studio/browser/DesignerHover/run.mjs new file mode 100644 index 0000000000..23bebac005 --- /dev/null +++ b/test/studio/browser/DesignerHover/run.mjs @@ -0,0 +1,312 @@ +import {chromium, expect as playwrightExpect} from '@playwright/test'; +import {build} from 'esbuild'; +import {createHash} from 'node:crypto'; +import {readFile, rename, writeFile} from 'node:fs/promises'; +import {createServer} from 'node:http'; +import {createRequire} from 'node:module'; +import {basename, dirname, join, relative, resolve} from 'node:path'; +import {fileURLToPath} from 'node:url'; +import {assertSourceHead, captureSourceBinding, verifySourceBinding} from './source-binding.mjs'; + +const fixtureDirectory = dirname(fileURLToPath(import.meta.url)); +const expect = playwrightExpect.configure({timeout: 10000}); +const root = resolve(fixtureDirectory, '../../../..'); +const clientPath = 'src/studio/modules/Elsa.Studio.Workflows.Designer/ClientLib'; +const helperPath = `${clientPath}/src/designer/api/edge-hover-tools.ts`; +const reviewedLockPath = 'scripts/integration-program/consolidated-build/studio-clientlib-lockfiles/designer.package-lock.json'; +const installedLockPath = `${clientPath}/package-lock.json`; +const assertions = [ + 'real-x6-rendered', 'repeated-edge-hover', 'edge-to-edge-transfer', + 'node-clears-hover', 'blank-clears-hover', 'outside-clears-hover', + 'remove-tool-entry', 'remove-tool-click', 'noninteractive-no-tools', +]; +const receipt = { + schema: 1, + kind: 'designer-hover-source-browser', + passed: false, + failure_category: null, + source: null, + dependencies: null, + browser: null, + bundle_sha256: null, + assertions: assertions.map(name => ({name, passed: false, reason_category: 'not_run'})), + events: null, + browser_errors: 0, + blocked_requests: 0, +}; +const hash = bytes => createHash('sha256').update(bytes).digest('hex'); +const json = async path => JSON.parse(await readFile(path, 'utf8')); +let stage = 'arguments'; +let browser; +let context; +let server; +let output; +let interrupted = false; + +// The outer hosted runner also has a timeout. These handlers close native resources on cancellation. +const stop = () => { + interrupted = true; + void browser?.close().catch(() => {}); + server?.closeAllConnections(); + server?.close(); +}; +process.once('SIGTERM', stop); +process.once('SIGINT', stop); + +try { + if (process.argv.length !== 6 || process.argv[2] !== '--expected-head' || process.argv[4] !== '--output') { + throw new Error('arguments'); + } + const expectedHead = process.argv[3]; + output = resolve(process.argv[5]); + stage = 'source_binding_initial'; + assertSourceHead(root, expectedHead); + stage = 'dependency_binding'; + const reviewedLock = await readFile(join(root, reviewedLockPath)); + const installedLock = await readFile(join(root, installedLockPath)); + expect(hash(installedLock)).toBe(hash(reviewedLock)); + const x6Lock = JSON.parse(reviewedLock).packages['node_modules/@antv/x6']; + const x6Directory = join(root, clientPath, 'node_modules/@antv/x6'); + const x6Package = await json(join(x6Directory, 'package.json')); + expect(x6Package.version).toBe(x6Lock.version); + expect(x6Lock.integrity).toMatch(/^sha512-[A-Za-z0-9+/]+=*$/); + const fixtureRequire = createRequire(join(fixtureDirectory, 'package.json')); + receipt.dependencies = { + x6: {version: x6Package.version, integrity: x6Lock.integrity}, + playwright: (await json(fixtureRequire.resolve('@playwright/test/package.json'))).version, + esbuild: (await json(fixtureRequire.resolve('esbuild/package.json'))).version, + }; + expect(receipt.dependencies.playwright).toBe('1.61.1'); + expect(receipt.dependencies.esbuild).toBe('0.28.2'); + + stage = 'bundle'; + const bundle = await build({ + absWorkingDir: root, + entryPoints: [join(fixtureDirectory, 'fixture.ts')], + outfile: 'fixture.js', + bundle: true, + write: false, + metafile: true, + platform: 'browser', + format: 'iife', + target: 'chrome120', + logLevel: 'silent', + // The fixture and production helper must share the mapped ClientLib's restored X6. + alias: {'@antv/x6': x6Directory}, + }); + expect(Object.keys(bundle.metafile.inputs)).toContain(helperPath); + const bundledPaths = Object.keys(bundle.metafile.inputs); + expect(bundledPaths.some(path => path.startsWith(`${clientPath}/node_modules/@antv/x6/`))).toBe(true); + expect(bundledPaths.filter(path => path.includes('/node_modules/@antv/x6/')) + .every(path => path.startsWith(`${clientPath}/node_modules/@antv/x6/`))).toBe(true); + const inputPaths = new Set([ + ...bundledPaths, + reviewedLockPath, + `${clientPath}/package.json`, + installedLockPath, + `${clientPath}/src/designer/api/create-graph.ts`, + `${clientPath}/src/designer/api/__tests__/edge-hover-tools.test.ts`, + `${clientPath}/vitest.config.ts`, + '.github/workflows/studio-clientlib-build.yml', + ...['run.mjs', 'fixture.ts', 'source-binding.mjs', 'source-binding.test.mjs', 'package.json', 'package-lock.json', 'README.md', '.gitignore'].map(name => relative(root, join(fixtureDirectory, name))), + ]); + receipt.source = await captureSourceBinding(root, [...inputPaths], expectedHead, [installedLockPath]); + const files = new Map(bundle.outputFiles.map(file => [`/${basename(file.path)}`, file.contents])); + expect(files.has('/fixture.js') && files.has('/fixture.css')).toBe(true); + receipt.bundle_sha256 = hash(files.get('/fixture.js')); + const html = Buffer.from('
'); + files.set('/', html); + server = createServer((request, response) => { + const body = files.get(request.url); + response.setHeader('Cache-Control', 'no-store'); + if (request.url === '/favicon.ico') { + response.writeHead(204).end(); + } else if (body) { + response.setHeader('Content-Type', request.url === '/' ? 'text/html' : request.url.endsWith('.css') ? 'text/css' : 'text/javascript'); + response.writeHead(200).end(body); + } else { + response.writeHead(404).end(); + } + }); + await new Promise((resolveListen, reject) => { + server.once('error', reject); + server.listen(0, '127.0.0.1', resolveListen); + }); + const origin = `http://127.0.0.1:${server.address().port}`; + + stage = 'browser_launch'; + browser = await chromium.launch({headless: true, timeout: 30000}); + receipt.browser = {engine: 'chromium', version: browser.version()}; + context = await browser.newContext({viewport: {width: 1000, height: 700}, serviceWorkers: 'block'}); + await context.route('**/*', route => { + if (new URL(route.request().url()).origin === origin) { + return route.continue(); + } + receipt.blocked_requests += 1; + return route.abort('blockedbyclient'); + }); + const page = await context.newPage(); + page.on('pageerror', () => { receipt.browser_errors += 1; }); + page.on('console', message => { + if (message.type() === 'error') { + receipt.browser_errors += 1; + } + }); + page.setDefaultTimeout(10000); + stage = 'page_load'; + await page.goto(origin, {waitUntil: 'load'}); + await page.waitForFunction(() => Boolean(window.hoverFixture)); + const snapshot = () => page.evaluate(() => window.hoverFixture.snapshot()); + const eventCount = async name => (await snapshot()).events[name] ?? 0; + const move = async (x, y) => { + const box = await page.locator('#graph').boundingBox(); + expect(box).not.toBeNull(); + await page.mouse.move(box.x + x, box.y + y, {steps: 8}); + // Flush rendering/event effects before checking absence; no timing sleeps. + await page.evaluate(() => new Promise(done => requestAnimationFrame(() => requestAnimationFrame(done)))); + }; + const edgePoint = id => id === 'edge-a' ? [320, 120] : [320, 240]; + const tool = (id, name) => page.locator(`.x6-cell-tools[data-cell-id="${id}"] [data-tool-name="${name}"]`); + const allButtons = page.locator('.x6-cell-tools [data-tool-name="button-remove"]'); + const checkEdge = async (id, buttons, vertices = 1) => { + await expect(tool(id, 'button-remove')).toHaveCount(buttons); + await expect(tool(id, 'vertices')).toHaveCount(vertices); + await expect.poll(async () => { + const edge = (await snapshot()).edges.find(edge => edge.id === id); + return {buttons: edge.buttons, vertices: edge.vertices}; + }).toEqual({buttons, vertices}); + }; + const hover = async id => { + await move(...edgePoint(id)); + await checkEdge(id, 1); + await expect(allButtons).toHaveCount(1); + }; + const run = async (name, action) => { + stage = name; + const result = receipt.assertions.find(result => result.name === name); + result.reason_category = 'assertion_failed'; + await action(); + expect(receipt.browser_errors).toBe(0); + expect(receipt.blocked_requests).toBe(0); + result.passed = true; + result.reason_category = null; + }; + + await run('real-x6-rendered', async () => { + await expect(page.locator('#graph .x6-graph-svg')).toBeVisible(); + await expect(page.locator('#graph .x6-edge')).toHaveCount(2); + expect((await snapshot()).edges.every(edge => edge.exists)).toBe(true); + }); + await run('repeated-edge-hover', async () => { + for (let repetition = 0; repetition < 5; repetition += 1) { + await move(400, 390); + await hover('edge-a'); + } + expect(await eventCount('edge:mouseenter:edge-a')).toBeGreaterThanOrEqual(5); + }); + await run('edge-to-edge-transfer', async () => { + await hover('edge-a'); + await hover('edge-b'); + await checkEdge('edge-a', 0); + expect(await eventCount('edge:mouseenter:edge-b')).toBeGreaterThan(0); + }); + for (const [name, point, event] of [ + ['node-clears-hover', [740, 120], 'node:mouseenter'], + ['blank-clears-hover', [400, 390], 'blank:mouseover'], + ['outside-clears-hover', [850, 240], 'graph:mouseleave'], + ]) { + await run(name, async () => { + await hover('edge-b'); + const before = await eventCount(event); + await move(...point); + await expect(allButtons).toHaveCount(0); + await checkEdge('edge-b', 0); + expect(await eventCount(event)).toBeGreaterThan(before); + }); + } + await run('remove-tool-entry', async () => { + await hover('edge-a'); + const button = tool('edge-a', 'button-remove').locator('circle'); + await expect(button).toBeVisible(); + const box = await button.boundingBox(); + expect(box).not.toBeNull(); + // Follow the edge to its remove control so entering the actual tool is exercised. + await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2, {steps: 16}); + await checkEdge('edge-a', 1); + expect(await page.evaluate(({x, y}) => document.elementFromPoint(x, y)?.closest('[data-tool-name]')?.getAttribute('data-tool-name'), + {x: box.x + box.width / 2, y: box.y + box.height / 2})).toBe('button-remove'); + }); + await run('remove-tool-click', async () => { + // Click at the pointer already on the real control; do not call model removal or synthetic events. + await page.mouse.down(); + await page.mouse.up(); + await expect.poll(async () => (await snapshot()).edges.find(edge => edge.id === 'edge-a').exists).toBe(false); + await expect(page.locator('.x6-edge[data-cell-id="edge-a"]')).toHaveCount(0); + await expect(allButtons).toHaveCount(0); + expect((await snapshot()).edges.find(edge => edge.id === 'edge-b').exists).toBe(true); + }); + const interactiveEvents = (await snapshot()).events; + await run('noninteractive-no-tools', async () => { + // A fresh graph prevents residual vertices from the interactive case from masking the boundary. + await move(850, 480); + await page.evaluate(() => window.hoverFixture.reset(false)); + for (let repetition = 0; repetition < 3; repetition += 1) { + await move(400, 390); + await move(...edgePoint('edge-a')); + await checkEdge('edge-a', 0, 0); + await move(...edgePoint('edge-b')); + await checkEdge('edge-b', 0, 0); + await expect(page.locator('.x6-cell-tool')).toHaveCount(0); + } + const state = await snapshot(); + expect(state.edges.every(edge => edge.exists)).toBe(true); + expect(await eventCount('edge:mouseenter:edge-a')).toBeGreaterThanOrEqual(3); + expect(await eventCount('edge:mouseenter:edge-b')).toBeGreaterThanOrEqual(3); + }); + receipt.events = {interactive: interactiveEvents, noninteractive: (await snapshot()).events}; + expect(interrupted).toBe(false); + receipt.passed = receipt.assertions.every(result => result.passed); +} catch { + receipt.failure_category = interrupted ? 'interrupted' : stage; +} finally { + // Teardown failures are acceptance failures too, without publishing native exception text. + for (const resource of [context, browser]) { + try { + await resource?.close(); + } catch { + receipt.passed = false; + receipt.failure_category = 'cleanup'; + } + } + try { + if (server?.listening) { + server.closeAllConnections(); + await new Promise((resolveClose, reject) => server.close(error => error ? reject(error) : resolveClose())); + } + } catch { + receipt.passed = false; + receipt.failure_category = 'cleanup'; + } + if (interrupted) { + receipt.passed = false; + receipt.failure_category = 'interrupted'; + } + if (receipt.passed) { + try { + await verifySourceBinding(root, receipt.source, [installedLockPath]); + } catch { + receipt.passed = false; + receipt.failure_category = 'source_binding_final'; + } + } + process.exitCode = receipt.passed ? 0 : 1; + if (output) { + try { + const temporary = `${output}.${process.pid}.tmp`; + await writeFile(temporary, `${JSON.stringify(receipt, null, 2)}\n`, {flag: 'wx', mode: 0o600}); + await rename(temporary, output); + } catch { + process.exitCode = 1; + } + } +} diff --git a/test/studio/browser/DesignerHover/source-binding.mjs b/test/studio/browser/DesignerHover/source-binding.mjs new file mode 100644 index 0000000000..40a5bc5295 --- /dev/null +++ b/test/studio/browser/DesignerHover/source-binding.mjs @@ -0,0 +1,47 @@ +import {execFileSync} from 'node:child_process'; +import {createHash} from 'node:crypto'; +import {readFile} from 'node:fs/promises'; +import {join, isAbsolute} from 'node:path'; + +const git = (root, args) => execFileSync('git', args, {cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore']}).trim(); +const hash = bytes => createHash('sha256').update(bytes).digest('hex'); + +export function assertSourceHead(root, expectedHead) { + if (!/^[0-9a-f]{40}$/.test(expectedHead) || git(root, ['rev-parse', 'HEAD']) !== expectedHead) { + throw new Error('source_head_mismatch'); + } +} + +function assertTrackedInputs(root, paths, generatedPaths) { + for (const path of paths) { + if (!path || isAbsolute(path) || path.split(/[\\/]/).includes('..')) { + throw new Error('source_input_path'); + } + } + const tracked = paths.filter(path => !path.split('/').includes('node_modules') && !generatedPaths.includes(path)); + git(root, ['ls-files', '--error-unmatch', '--', ...tracked]); + git(root, ['diff', '--quiet', 'HEAD', '--', ...tracked]); +} + +export async function captureSourceBinding(root, paths, expectedHead, generatedPaths = []) { + assertSourceHead(root, expectedHead); + const sorted = [...new Set(paths)].sort(); + assertTrackedInputs(root, sorted, generatedPaths); + const inputs = []; + for (const path of sorted) { + const bytes = await readFile(join(root, path)); + inputs.push({path, sha256: hash(bytes), bytes: bytes.length}); + } + return {commit: expectedHead, inputs}; +} + +export async function verifySourceBinding(root, binding, generatedPaths = []) { + assertSourceHead(root, binding.commit); + assertTrackedInputs(root, binding.inputs.map(input => input.path), generatedPaths); + for (const input of binding.inputs) { + const bytes = await readFile(join(root, input.path)); + if (hash(bytes) !== input.sha256 || bytes.length !== input.bytes) { + throw new Error('source_input_changed'); + } + } +} diff --git a/test/studio/browser/DesignerHover/source-binding.test.mjs b/test/studio/browser/DesignerHover/source-binding.test.mjs new file mode 100644 index 0000000000..e479de9832 --- /dev/null +++ b/test/studio/browser/DesignerHover/source-binding.test.mjs @@ -0,0 +1,73 @@ +import assert from 'node:assert/strict'; +import {execFileSync} from 'node:child_process'; +import {mkdtemp, mkdir, rm, writeFile} from 'node:fs/promises'; +import {tmpdir} from 'node:os'; +import {join} from 'node:path'; +import {test} from 'node:test'; +import {assertSourceHead, captureSourceBinding, verifySourceBinding} from './source-binding.mjs'; + +async function fixture(t) { + const root = await mkdtemp(join(tmpdir(), 'designer-hover-binding-')); + t.after(() => rm(root, {recursive: true, force: true})); + const git = args => execFileSync('git', args, {cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore']}).trim(); + git(['init', '--quiet']); + await writeFile(join(root, 'source.ts'), 'export const value = 1;\n'); + git(['add', 'source.ts']); + git(['-c', 'user.name=Fixture', '-c', 'user.email=fixture@example.invalid', 'commit', '--quiet', '-m', 'fixture']); + await mkdir(join(root, 'node_modules')); + await writeFile(join(root, 'node_modules/runtime.js'), 'runtime'); + await writeFile(join(root, 'restored.lock'), 'lock'); + const head = git(['rev-parse', 'HEAD']); + const paths = ['source.ts', 'node_modules/runtime.js', 'restored.lock']; + const generated = ['restored.lock']; + return {root, head, paths, generated, git}; +} + +test('unchanged committed source and restored inputs pass both guards', async t => { + const f = await fixture(t); + const binding = await captureSourceBinding(f.root, f.paths, f.head, f.generated); + await verifySourceBinding(f.root, binding, f.generated); + assert.equal(binding.commit, f.head); + assert.equal(binding.inputs.length, 3); +}); + +test('wrong expected head fails before capture', async t => { + const f = await fixture(t); + assert.throws(() => assertSourceHead(f.root, '0'.repeat(40)), /source_head_mismatch/); + assert.throws(() => assertSourceHead(f.root, undefined), /source_head_mismatch/); +}); + +test('tracked source edits fail before capture', async t => { + const f = await fixture(t); + await writeFile(join(f.root, 'source.ts'), 'changed'); + await assert.rejects(captureSourceBinding(f.root, f.paths, f.head, f.generated)); +}); + +test('tracked source edits after capture fail final validation', async t => { + const f = await fixture(t); + const binding = await captureSourceBinding(f.root, f.paths, f.head, f.generated); + await writeFile(join(f.root, 'source.ts'), 'changed'); + await assert.rejects(verifySourceBinding(f.root, binding, f.generated)); +}); + +for (const path of ['node_modules/runtime.js', 'restored.lock']) { + test(`changed ${path} fails final hash validation`, async t => { + const f = await fixture(t); + const binding = await captureSourceBinding(f.root, f.paths, f.head, f.generated); + await writeFile(join(f.root, path), 'changed'); + await assert.rejects(verifySourceBinding(f.root, binding, f.generated), /source_input_changed/); + }); +} + +test('changed HEAD fails final validation even when input bytes match', async t => { + const f = await fixture(t); + const binding = await captureSourceBinding(f.root, f.paths, f.head, f.generated); + f.git(['-c', 'user.name=Fixture', '-c', 'user.email=fixture@example.invalid', 'commit', '--allow-empty', '--quiet', '-m', 'new head']); + await assert.rejects(verifySourceBinding(f.root, binding, f.generated), /source_head_mismatch/); +}); + +test('untracked source cannot be attributed to HEAD', async t => { + const f = await fixture(t); + await writeFile(join(f.root, 'untracked.ts'), 'untracked'); + await assert.rejects(captureSourceBinding(f.root, [...f.paths, 'untracked.ts'], f.head, f.generated)); +}); From 605b8548f3ecabf7c2fb450ed1405496e56c4f2b Mon Sep 17 00:00:00 2001 From: Sipke Schoorstra Date: Wed, 7 Oct 2026 23:59:48 +0200 Subject: [PATCH 5/9] ci(studio): bind ClientLib and hover proof to exact source head --- .github/workflows/studio-clientlib-build.yml | 44 +++++++++++++++++++- 1 file changed, 43 insertions(+), 1 deletion(-) diff --git a/.github/workflows/studio-clientlib-build.yml b/.github/workflows/studio-clientlib-build.yml index 5d2d638022..86a7b5204e 100644 --- a/.github/workflows/studio-clientlib-build.yml +++ b/.github/workflows/studio-clientlib-build.yml @@ -10,19 +10,33 @@ on: - 'scripts/integration-program/build_studio_clientlibs.sh' - 'scripts/integration-program/consolidated-build/studio-bpmn-generator-layout.patch' - 'scripts/integration-program/consolidated-build/studio-clientlib-lockfiles/**' + - 'test/studio/browser/DesignerHover/**' - '.github/workflows/studio-clientlib-build.yml' workflow_dispatch: permissions: contents: read +concurrency: + group: studio-clientlib-build-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + jobs: studio-clientlibs: - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 + timeout-minutes: 30 + env: + EXPECTED_HEAD: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }} steps: - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 with: + repository: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name || github.repository }} + ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }} persist-credentials: false + - name: Verify exact candidate checkout + run: | + test "$(git rev-parse HEAD)" = "$EXPECTED_HEAD" + git diff --exit-code HEAD -- - uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4.3.1 with: dotnet-version: '10.0.300' @@ -34,6 +48,9 @@ jobs: run: | if test -f src/studio/modules/Elsa.Studio.Workflows.Designer/ClientLib/package.json; then echo 'mapped=true' >> "$GITHUB_OUTPUT" + elif test -d src/studio; then + echo 'Imported Studio is missing the required Designer ClientLib manifest.' >&2 + exit 1 else echo 'mapped=false' >> "$GITHUB_OUTPUT" fi @@ -49,6 +66,31 @@ jobs: if: steps.source.outputs.mapped == 'false' run: | scripts/integration-program/build_studio_clientlibs.sh pinned-studio + - name: Verify browser proof source-binding guards + if: steps.source.outputs.mapped == 'true' + working-directory: test/studio/browser/DesignerHover + run: | + npm ci + npm test - name: Build consolidated Studio ClientLibs after import if: steps.source.outputs.mapped == 'true' run: scripts/integration-program/build_studio_clientlibs.sh . + - name: Verify real X6 edge-hover behavior + if: steps.source.outputs.mapped == 'true' + working-directory: test/studio/browser/DesignerHover + run: | + npx --no-install playwright install --with-deps chromium + node run.mjs --expected-head "$EXPECTED_HEAD" --output "$RUNNER_TEMP/designer-hover-browser.json" + - name: Verify source remained unchanged + if: always() + run: | + test "$(git rev-parse HEAD)" = "$EXPECTED_HEAD" + git diff --exit-code HEAD -- + - name: Retain sanitized hover evidence + if: always() && steps.source.outputs.mapped == 'true' + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: designer-hover-browser-${{ github.run_id }}-${{ github.run_attempt }} + path: ${{ runner.temp }}/designer-hover-browser.json + if-no-files-found: error + retention-days: 14 From e244afdfec20ecbcb5daf00b50b91f9a3794c6f8 Mon Sep 17 00:00:00 2001 From: Sipke Schoorstra Date: Thu, 8 Oct 2026 00:08:01 +0200 Subject: [PATCH 6/9] test(studio): observe native graph boundary and retain hover diagnostics --- test/studio/browser/DesignerHover/README.md | 6 +- test/studio/browser/DesignerHover/fixture.ts | 22 +++++- test/studio/browser/DesignerHover/run.mjs | 74 ++++++++++++++++++-- 3 files changed, 90 insertions(+), 12 deletions(-) diff --git a/test/studio/browser/DesignerHover/README.md b/test/studio/browser/DesignerHover/README.md index 51346915b8..21062fe08b 100644 --- a/test/studio/browser/DesignerHover/README.md +++ b/test/studio/browser/DesignerHover/README.md @@ -30,15 +30,15 @@ The receipt has exactly nine assertion records, all required to pass: 3. `edge-to-edge-transfer`: A's button clears and B has exactly one; A's vertex tool remains. 4. `node-clears-hover`: native node entry clears the button. 5. `blank-clears-hover`: native blank hover clears the button without a held mouse button. -6. `outside-clears-hover`: B extends to the graph boundary; moving directly along B to outside clears its button and produces a graph leave. +6. `outside-clears-hover`: B extends to the graph boundary; moving directly along B to outside clears its button, retains its vertex tool and produces a native container leave. X6 edge/graph leave counters are retained separately because its mouseout-based graph-leave emulation can preserve the edge target and emit only an edge leave on this path. 7. `remove-tool-entry`: pointer reaches the real button hit target along A; its button survives. 8. `remove-tool-click`: native mouse down/up removes A from the model and DOM, retaining B. -9. `noninteractive-no-tools`: a fresh disabled graph receives repeated native entries on A/B, retains both edges and creates no model or rendered tools. +9. `noninteractive-no-tools`: a fresh disabled graph receives repeated native entries on A/B, retains both edges and creates no model or rendered tools. A subsequent boundary exit increments its native leave counter exactly once, guarding listener cleanup across graph reset. The stale-A-leave-after-B regression is a separate synthetic source unit test. This browser proof does not replay that event sequence. ## Receipt and lifecycle -`--expected-head` and `--output` are required; the output parent directory must already exist. Exit zero requires all nine assertions, zero browser errors and zero external browser requests. Failure records contain only fixed stage/assertion categories, never exception text, console messages, URLs, screenshots or traces. The page is served from an ephemeral loopback port and external browser requests are blocked. Browser/context/server cleanup runs in `finally`, and signal handlers close native resources. Playwright actions/assertions have finite timeouts; the hosted wrapper must also bound the complete process. +`--expected-head` and `--output` are required; the output parent directory must already exist. Exit zero requires all nine assertions, zero browser errors and zero external browser requests. Every attempted assertion captures its current fixed subcheck, model/rendered tool counts, cell-existence and pointer-boundary booleans, and native/X6 event counts before teardown, including on failure. Unattempted assertions retain a null observation. Failure records contain no exception text, console messages, URLs, screenshots or traces. The page is served from an ephemeral loopback port and external browser requests are blocked. Native boundary listeners are aborted before each graph reset and on page disposal. Browser/context/server cleanup runs in `finally`, and signal handlers close native resources. Playwright actions/assertions have finite timeouts; the hosted wrapper must also bound the complete process. The JSON binds the Git source commit, production helper/create-graph/unit-test/Vitest configuration, reviewed and installed ClientLib locks, ClientLib package manifest, source workflow, all fixture source files, and every actual esbuild input to SHA-256 and byte length. It records the exact installed X6/Playwright/esbuild and launched Chromium versions, the JavaScript bundle digest, and native event counts for interactive/noninteractive graphs. The runner requires the expected Git HEAD and clean tracked inputs initially, then rechecks HEAD, tracked cleanliness and every input hash after browser teardown before accepting the result. Eight pure Node guard regressions cover unchanged inputs, mismatched/missing expected head, pre/post tracked edits, restored dependency/lock changes, changed HEAD and untracked source. The receipt is written atomically with mode 0600. Do not accept merely a prior-head receipt or a command that omitted required assertions. diff --git a/test/studio/browser/DesignerHover/fixture.ts b/test/studio/browser/DesignerHover/fixture.ts index 957e799c63..45909f98e5 100644 --- a/test/studio/browser/DesignerHover/fixture.ts +++ b/test/studio/browser/DesignerHover/fixture.ts @@ -6,10 +6,14 @@ import {registerEdgeHoverTools} from '../../../../src/studio/modules/Elsa.Studio const container = document.getElementById('graph')!; let graph: Graph; let events: Record; +let pointerInside = false; +let observationController: AbortController; function reset(enabled: boolean) { + observationController?.abort(); graph?.dispose(); events = {}; + pointerInside = container.matches(':hover'); graph = new Graph({ container, width: 800, @@ -19,12 +23,22 @@ function reset(enabled: boolean) { background: {color: '#fafafa'}, }); registerEdgeHoverTools(graph, enabled); - for (const event of ['edge:mouseenter', 'node:mouseenter', 'blank:mouseover', 'graph:mouseleave'] as const) { + for (const event of ['edge:mouseenter', 'edge:mouseleave', 'node:mouseenter', 'blank:mouseover', 'graph:mouseleave'] as const) { graph.on(event, (args: {edge?: {id: string}}) => { const key = args.edge ? `${event}:${args.edge.id}` : event; events[key] = (events[key] ?? 0) + 1; }); } + // X6 emulates leave from mouseout and can retain a descendant edge as its event target. + // Observe the actual container boundary independently; never manufacture a graph event. + observationController = new AbortController(); + for (const event of ['mouseenter', 'mouseleave'] as const) { + container.addEventListener(event, () => { + pointerInside = event === 'mouseenter'; + const key = `container:${event}`; + events[key] = (events[key] ?? 0) + 1; + }, {signal: observationController.signal}); + } graph.addNode({id: 'node', x: 700, y: 90, width: 80, height: 60, label: 'Node'}); graph.addEdge({id: 'edge-a', source: {x: 100, y: 120}, target: {x: 660, y: 120}}); // B reaches the boundary, allowing a direct edge-to-outside transition without crossing blank canvas. @@ -34,6 +48,7 @@ function reset(enabled: boolean) { function snapshot() { return { events: {...events}, + pointerInside, edges: ['edge-a', 'edge-b'].map(id => { const edge = graph.getCellById(id); const items = edge?.getTools()?.items ?? []; @@ -50,4 +65,7 @@ function snapshot() { reset(true); Object.assign(window, {hoverFixture: {reset, snapshot}}); -window.addEventListener('pagehide', () => graph.dispose(), {once: true}); +window.addEventListener('pagehide', () => { + observationController.abort(); + graph.dispose(); +}, {once: true}); diff --git a/test/studio/browser/DesignerHover/run.mjs b/test/studio/browser/DesignerHover/run.mjs index 23bebac005..43316852ac 100644 --- a/test/studio/browser/DesignerHover/run.mjs +++ b/test/studio/browser/DesignerHover/run.mjs @@ -29,7 +29,7 @@ const receipt = { dependencies: null, browser: null, bundle_sha256: null, - assertions: assertions.map(name => ({name, passed: false, reason_category: 'not_run'})), + assertions: assertions.map(name => ({name, passed: false, reason_category: 'not_run', observation: null})), events: null, browser_errors: 0, blocked_requests: 0, @@ -158,7 +158,9 @@ try { await page.waitForFunction(() => Boolean(window.hoverFixture)); const snapshot = () => page.evaluate(() => window.hoverFixture.snapshot()); const eventCount = async name => (await snapshot()).events[name] ?? 0; + let subcheck = 'action'; const move = async (x, y) => { + subcheck = 'pointer-move'; const box = await page.locator('#graph').boundingBox(); expect(box).not.toBeNull(); await page.mouse.move(box.x + x, box.y + y, {steps: 8}); @@ -169,8 +171,11 @@ try { const tool = (id, name) => page.locator(`.x6-cell-tools[data-cell-id="${id}"] [data-tool-name="${name}"]`); const allButtons = page.locator('.x6-cell-tools [data-tool-name="button-remove"]'); const checkEdge = async (id, buttons, vertices = 1) => { + subcheck = 'rendered-edge-buttons'; await expect(tool(id, 'button-remove')).toHaveCount(buttons); + subcheck = 'rendered-edge-vertices'; await expect(tool(id, 'vertices')).toHaveCount(vertices); + subcheck = 'model-edge-tools'; await expect.poll(async () => { const edge = (await snapshot()).edges.find(edge => edge.id === id); return {buttons: edge.buttons, vertices: edge.vertices}; @@ -179,17 +184,60 @@ try { const hover = async id => { await move(...edgePoint(id)); await checkEdge(id, 1); + subcheck = 'single-remove-button'; await expect(allButtons).toHaveCount(1); }; + const observation = async () => { + const state = await snapshot(); + const [a, b] = state.edges; + return { + subcheck, + pointer_inside: state.pointerInside, + edge_a_exists: a.exists, + edge_b_exists: b.exists, + model_a_buttons: a.buttons, + model_b_buttons: b.buttons, + model_a_vertices: a.vertices, + model_b_vertices: b.vertices, + rendered_buttons: await allButtons.count(), + rendered_a_buttons: await tool('edge-a', 'button-remove').count(), + rendered_b_buttons: await tool('edge-b', 'button-remove').count(), + rendered_a_vertices: await tool('edge-a', 'vertices').count(), + rendered_b_vertices: await tool('edge-b', 'vertices').count(), + native_container_enters: state.events['container:mouseenter'] ?? 0, + native_container_leaves: state.events['container:mouseleave'] ?? 0, + x6_a_enters: state.events['edge:mouseenter:edge-a'] ?? 0, + x6_b_enters: state.events['edge:mouseenter:edge-b'] ?? 0, + x6_a_leaves: state.events['edge:mouseleave:edge-a'] ?? 0, + x6_b_leaves: state.events['edge:mouseleave:edge-b'] ?? 0, + x6_graph_leaves: state.events['graph:mouseleave'] ?? 0, + x6_node_enters: state.events['node:mouseenter'] ?? 0, + x6_blank_overs: state.events['blank:mouseover'] ?? 0, + }; + }; const run = async (name, action) => { stage = name; + subcheck = 'action'; const result = receipt.assertions.find(result => result.name === name); result.reason_category = 'assertion_failed'; - await action(); - expect(receipt.browser_errors).toBe(0); - expect(receipt.blocked_requests).toBe(0); - result.passed = true; - result.reason_category = null; + try { + await action(); + subcheck = 'browser-errors'; + expect(receipt.browser_errors).toBe(0); + subcheck = 'external-requests'; + expect(receipt.blocked_requests).toBe(0); + result.passed = true; + result.reason_category = null; + subcheck = 'completed'; + } finally { + try { + result.observation = await observation(); + } catch { + result.passed = false; + result.reason_category = 'observation_failed'; + throw new Error('observation_failed'); + } + } }; await run('real-x6-rendered', async () => { @@ -213,15 +261,21 @@ try { for (const [name, point, event] of [ ['node-clears-hover', [740, 120], 'node:mouseenter'], ['blank-clears-hover', [400, 390], 'blank:mouseover'], - ['outside-clears-hover', [850, 240], 'graph:mouseleave'], + ['outside-clears-hover', [850, 240], 'container:mouseleave'], ]) { await run(name, async () => { await hover('edge-b'); const before = await eventCount(event); await move(...point); + subcheck = 'all-remove-buttons-cleared'; await expect(allButtons).toHaveCount(0); await checkEdge('edge-b', 0); + subcheck = name === 'outside-clears-hover' ? 'native-container-leave' : 'native-event-count'; expect(await eventCount(event)).toBeGreaterThan(before); + if (name === 'outside-clears-hover') { + subcheck = 'pointer-outside-container'; + expect((await snapshot()).pointerInside).toBe(false); + } }); } await run('remove-tool-entry', async () => { @@ -262,6 +316,12 @@ try { expect(state.edges.every(edge => edge.exists)).toBe(true); expect(await eventCount('edge:mouseenter:edge-a')).toBeGreaterThanOrEqual(3); expect(await eventCount('edge:mouseenter:edge-b')).toBeGreaterThanOrEqual(3); + const leavesBefore = await eventCount('container:mouseleave'); + await move(850, 240); + subcheck = 'native-listener-cleanup'; + // The previous graph's native listeners must have been aborted during reset. + expect(await eventCount('container:mouseleave')).toBe(leavesBefore + 1); + expect((await snapshot()).pointerInside).toBe(false); }); receipt.events = {interactive: interactiveEvents, noninteractive: (await snapshot()).events}; expect(interrupted).toBe(false); From 2bec5492c234c2f9a4c7300cd6f70fd7cd0cb3a3 Mon Sep 17 00:00:00 2001 From: Sipke Schoorstra Date: Thu, 8 Oct 2026 00:15:52 +0200 Subject: [PATCH 7/9] test(studio): verify trusted pointer exit with independent geometry --- test/studio/browser/DesignerHover/README.md | 4 +- test/studio/browser/DesignerHover/fixture.ts | 39 ++++++++++++++------ test/studio/browser/DesignerHover/run.mjs | 29 ++++++++++----- 3 files changed, 49 insertions(+), 23 deletions(-) diff --git a/test/studio/browser/DesignerHover/README.md b/test/studio/browser/DesignerHover/README.md index 21062fe08b..3b138d938a 100644 --- a/test/studio/browser/DesignerHover/README.md +++ b/test/studio/browser/DesignerHover/README.md @@ -30,7 +30,7 @@ The receipt has exactly nine assertion records, all required to pass: 3. `edge-to-edge-transfer`: A's button clears and B has exactly one; A's vertex tool remains. 4. `node-clears-hover`: native node entry clears the button. 5. `blank-clears-hover`: native blank hover clears the button without a held mouse button. -6. `outside-clears-hover`: B extends to the graph boundary; moving directly along B to outside clears its button, retains its vertex tool and produces a native container leave. X6 edge/graph leave counters are retained separately because its mouseout-based graph-leave emulation can preserve the edge target and emit only an edge leave on this path. +6. `outside-clears-hover`: B extends to the graph boundary; moving directly along B to outside clears its button, retains its vertex tool and produces a trusted native root `pointerleave`. Independently require an actual 800x440 container, CSS `:hover` false, and the last trusted pointer position outside both its rectangle and DOM hit subtree. X6 edge/graph leave counters are retained separately because its mouseout-based graph-leave emulation can preserve the edge target and emit only an edge leave on this path. 7. `remove-tool-entry`: pointer reaches the real button hit target along A; its button survives. 8. `remove-tool-click`: native mouse down/up removes A from the model and DOM, retaining B. 9. `noninteractive-no-tools`: a fresh disabled graph receives repeated native entries on A/B, retains both edges and creates no model or rendered tools. A subsequent boundary exit increments its native leave counter exactly once, guarding listener cleanup across graph reset. @@ -39,6 +39,6 @@ The stale-A-leave-after-B regression is a separate synthetic source unit test. T ## Receipt and lifecycle -`--expected-head` and `--output` are required; the output parent directory must already exist. Exit zero requires all nine assertions, zero browser errors and zero external browser requests. Every attempted assertion captures its current fixed subcheck, model/rendered tool counts, cell-existence and pointer-boundary booleans, and native/X6 event counts before teardown, including on failure. Unattempted assertions retain a null observation. Failure records contain no exception text, console messages, URLs, screenshots or traces. The page is served from an ephemeral loopback port and external browser requests are blocked. Native boundary listeners are aborted before each graph reset and on page disposal. Browser/context/server cleanup runs in `finally`, and signal handlers close native resources. Playwright actions/assertions have finite timeouts; the hosted wrapper must also bound the complete process. +`--expected-head` and `--output` are required; the output parent directory must already exist. Exit zero requires all nine assertions, zero browser errors and zero external browser requests. Every attempted assertion captures its current fixed subcheck, model/rendered tool counts, cell-existence and pointer-boundary booleans, actual container width/height, and native/X6 event counts before teardown, including on failure. Unattempted assertions retain a null observation. Failure records contain no exception text, console messages, URLs, pointer coordinates, screenshots or traces. Native enter/leave counters accept only trusted mouse PointerEvents whose target is the root container; capture-phase listeners exclude descendant notifications and run independently of X6's mouse-event handlers. Pointer-inside state comes from actual CSS `:hover`, not callback state. The last trusted mouse pointer move is observed on the document in capture phase, with only its geometric/hit-test booleans retained. Native listeners are aborted before each graph reset and on page disposal. The page is served from an ephemeral loopback port and external browser requests are blocked. Browser/context/server cleanup runs in `finally`, and signal handlers close native resources. Playwright actions/assertions have finite timeouts; the hosted wrapper must also bound the complete process. The JSON binds the Git source commit, production helper/create-graph/unit-test/Vitest configuration, reviewed and installed ClientLib locks, ClientLib package manifest, source workflow, all fixture source files, and every actual esbuild input to SHA-256 and byte length. It records the exact installed X6/Playwright/esbuild and launched Chromium versions, the JavaScript bundle digest, and native event counts for interactive/noninteractive graphs. The runner requires the expected Git HEAD and clean tracked inputs initially, then rechecks HEAD, tracked cleanliness and every input hash after browser teardown before accepting the result. Eight pure Node guard regressions cover unchanged inputs, mismatched/missing expected head, pre/post tracked edits, restored dependency/lock changes, changed HEAD and untracked source. The receipt is written atomically with mode 0600. Do not accept merely a prior-head receipt or a command that omitted required assertions. diff --git a/test/studio/browser/DesignerHover/fixture.ts b/test/studio/browser/DesignerHover/fixture.ts index 45909f98e5..9a0fb9ed3c 100644 --- a/test/studio/browser/DesignerHover/fixture.ts +++ b/test/studio/browser/DesignerHover/fixture.ts @@ -6,14 +6,14 @@ import {registerEdgeHoverTools} from '../../../../src/studio/modules/Elsa.Studio const container = document.getElementById('graph')!; let graph: Graph; let events: Record; -let pointerInside = false; +let lastTrustedPointer: {x: number; y: number} | null = null; let observationController: AbortController; function reset(enabled: boolean) { observationController?.abort(); graph?.dispose(); events = {}; - pointerInside = container.matches(':hover'); + lastTrustedPointer = null; graph = new Graph({ container, width: 800, @@ -29,16 +29,24 @@ function reset(enabled: boolean) { events[key] = (events[key] ?? 0) + 1; }); } - // X6 emulates leave from mouseout and can retain a descendant edge as its event target. - // Observe the actual container boundary independently; never manufacture a graph event. + // X6 handles mouse/touch events. Observe untouched trusted pointer events at the actual root + // in capture phase, independently of its emulated mouseenter/leave and descendant events. observationController = new AbortController(); - for (const event of ['mouseenter', 'mouseleave'] as const) { - container.addEventListener(event, () => { - pointerInside = event === 'mouseenter'; - const key = `container:${event}`; - events[key] = (events[key] ?? 0) + 1; - }, {signal: observationController.signal}); - } + const observeBoundary = (observed: PointerEvent) => { + if (!observed.isTrusted || observed.target !== container || observed.pointerType !== 'mouse') { + return; + } + const key = `container:${observed.type}`; + events[key] = (events[key] ?? 0) + 1; + }; + const observationOptions = {capture: true, signal: observationController.signal}; + container.addEventListener('pointerenter', observeBoundary, observationOptions); + container.addEventListener('pointerleave', observeBoundary, observationOptions); + document.addEventListener('pointermove', observed => { + if (observed.isTrusted && observed.pointerType === 'mouse') { + lastTrustedPointer = {x: observed.clientX, y: observed.clientY}; + } + }, observationOptions); graph.addNode({id: 'node', x: 700, y: 90, width: 80, height: 60, label: 'Node'}); graph.addEdge({id: 'edge-a', source: {x: 100, y: 120}, target: {x: 660, y: 120}}); // B reaches the boundary, allowing a direct edge-to-outside transition without crossing blank canvas. @@ -46,9 +54,16 @@ function reset(enabled: boolean) { } function snapshot() { + const rect = container.getBoundingClientRect(); + const point = lastTrustedPointer; + const hit = point ? document.elementFromPoint(point.x, point.y) : null; return { events: {...events}, - pointerInside, + pointerInside: container.matches(':hover'), + bounds: {width: rect.width, height: rect.height}, + trustedPointerSeen: point !== null, + trustedPointerInsideRect: point !== null && point.x >= rect.left && point.x < rect.right && point.y >= rect.top && point.y < rect.bottom, + trustedPointerHitsGraph: hit !== null && container.contains(hit), edges: ['edge-a', 'edge-b'].map(id => { const edge = graph.getCellById(id); const items = edge?.getTools()?.items ?? []; diff --git a/test/studio/browser/DesignerHover/run.mjs b/test/studio/browser/DesignerHover/run.mjs index 43316852ac..7e9af80ab7 100644 --- a/test/studio/browser/DesignerHover/run.mjs +++ b/test/studio/browser/DesignerHover/run.mjs @@ -193,6 +193,11 @@ try { return { subcheck, pointer_inside: state.pointerInside, + graph_width: state.bounds.width, + graph_height: state.bounds.height, + trusted_pointer_seen: state.trustedPointerSeen, + trusted_pointer_inside_rect: state.trustedPointerInsideRect, + trusted_pointer_hits_graph: state.trustedPointerHitsGraph, edge_a_exists: a.exists, edge_b_exists: b.exists, model_a_buttons: a.buttons, @@ -204,8 +209,8 @@ try { rendered_b_buttons: await tool('edge-b', 'button-remove').count(), rendered_a_vertices: await tool('edge-a', 'vertices').count(), rendered_b_vertices: await tool('edge-b', 'vertices').count(), - native_container_enters: state.events['container:mouseenter'] ?? 0, - native_container_leaves: state.events['container:mouseleave'] ?? 0, + native_container_enters: state.events['container:pointerenter'] ?? 0, + native_container_leaves: state.events['container:pointerleave'] ?? 0, x6_a_enters: state.events['edge:mouseenter:edge-a'] ?? 0, x6_b_enters: state.events['edge:mouseenter:edge-b'] ?? 0, x6_a_leaves: state.events['edge:mouseleave:edge-a'] ?? 0, @@ -244,6 +249,7 @@ try { await expect(page.locator('#graph .x6-graph-svg')).toBeVisible(); await expect(page.locator('#graph .x6-edge')).toHaveCount(2); expect((await snapshot()).edges.every(edge => edge.exists)).toBe(true); + expect((await snapshot()).bounds).toEqual({width: 800, height: 440}); }); await run('repeated-edge-hover', async () => { for (let repetition = 0; repetition < 5; repetition += 1) { @@ -261,7 +267,7 @@ try { for (const [name, point, event] of [ ['node-clears-hover', [740, 120], 'node:mouseenter'], ['blank-clears-hover', [400, 390], 'blank:mouseover'], - ['outside-clears-hover', [850, 240], 'container:mouseleave'], + ['outside-clears-hover', [850, 240], 'container:pointerleave'], ]) { await run(name, async () => { await hover('edge-b'); @@ -270,12 +276,17 @@ try { subcheck = 'all-remove-buttons-cleared'; await expect(allButtons).toHaveCount(0); await checkEdge('edge-b', 0); - subcheck = name === 'outside-clears-hover' ? 'native-container-leave' : 'native-event-count'; - expect(await eventCount(event)).toBeGreaterThan(before); if (name === 'outside-clears-hover') { - subcheck = 'pointer-outside-container'; - expect((await snapshot()).pointerInside).toBe(false); + subcheck = 'outside-geometry'; + const state = await snapshot(); + expect(state.bounds).toEqual({width: 800, height: 440}); + expect(state.trustedPointerSeen).toBe(true); + expect(state.trustedPointerInsideRect).toBe(false); + expect(state.trustedPointerHitsGraph).toBe(false); + expect(state.pointerInside).toBe(false); } + subcheck = name === 'outside-clears-hover' ? 'native-container-leave' : 'native-event-count'; + expect(await eventCount(event)).toBeGreaterThan(before); }); } await run('remove-tool-entry', async () => { @@ -316,11 +327,11 @@ try { expect(state.edges.every(edge => edge.exists)).toBe(true); expect(await eventCount('edge:mouseenter:edge-a')).toBeGreaterThanOrEqual(3); expect(await eventCount('edge:mouseenter:edge-b')).toBeGreaterThanOrEqual(3); - const leavesBefore = await eventCount('container:mouseleave'); + const leavesBefore = await eventCount('container:pointerleave'); await move(850, 240); subcheck = 'native-listener-cleanup'; // The previous graph's native listeners must have been aborted during reset. - expect(await eventCount('container:mouseleave')).toBe(leavesBefore + 1); + expect(await eventCount('container:pointerleave')).toBe(leavesBefore + 1); expect((await snapshot()).pointerInside).toBe(false); }); receipt.events = {interactive: interactiveEvents, noninteractive: (await snapshot()).events}; From 79b0c5ce562cb302cbd1c4a8f85bfbae85812dba Mon Sep 17 00:00:00 2001 From: Sipke Schoorstra Date: Thu, 8 Oct 2026 00:42:33 +0200 Subject: [PATCH 8/9] test(studio): isolate browser hover destination handlers --- test/studio/browser/DesignerHover/README.md | 17 ++- test/studio/browser/DesignerHover/fixture.ts | 109 ++++++++++++++++--- test/studio/browser/DesignerHover/run.mjs | 69 +++++++++++- 3 files changed, 174 insertions(+), 21 deletions(-) diff --git a/test/studio/browser/DesignerHover/README.md b/test/studio/browser/DesignerHover/README.md index 3b138d938a..43ed47276d 100644 --- a/test/studio/browser/DesignerHover/README.md +++ b/test/studio/browser/DesignerHover/README.md @@ -1,6 +1,6 @@ # Designer edge-hover source browser proof -This isolated Chromium smoke imports the production `registerEdgeHoverTools` helper from the mapped Studio ClientLib and bundles the ClientLib's restored X6. It uses native pointer movement and a real X6 remove-button click. The page exposes fresh-graph setup and observations; it does not synthesize graph events or remove edges through the model. +This isolated Chromium smoke imports the production `registerEdgeHoverTools` helper from the mapped Studio ClientLib and bundles the ClientLib's restored X6. It uses native pointer movement and a real X6 remove-button click. The page exposes fresh-graph setup, observations and an explicitly selected supplemental notification adapter; it does not synthesize graph events or remove edges through the model. The nine primary cases use unfiltered production subscriptions. This proves the helper's browser behavior at one exact source head. The ClientLib Vitest/typecheck/production build gate separately proves its integration into `createGraph`. It is not the paused #8643 paired-package/browser matrix, a full Studio host journey, or npm publication evidence. @@ -23,7 +23,7 @@ Playwright 1.61.1 and esbuild 0.28.2 are pinned from the existing paused Package ## Acceptance manifest -The receipt has exactly nine assertion records, all required to pass: +The receipt has exactly nine primary assertion records, all required to pass: 1. `real-x6-rendered`: two real rendered edges and matching X6 model cells. 2. `repeated-edge-hover`: five native entries on A, exactly one button and one vertex tool in both rendered DOM and model on each entry. @@ -37,8 +37,19 @@ The receipt has exactly nine assertion records, all required to pass: The stale-A-leave-after-B regression is a separate synthetic source unit test. This browser proof does not replay that event sequence. +The natural edge-to-edge and edge-to-node paths can clear A through edge leave or intervening blank hover before the destination callback runs. Those primary cases establish the visible outcome, not independent coverage of each fallback handler. Four additional `supplemental_assertions` isolate destination handling and are also mandatory: + +| Supplemental assertion | Destination callback | Required old-A clear predicate | +| --- | --- | --- | +| `isolated-edge-target-clears-old-button` | B entry delivered | True; B also has its normal button/vertex tools | +| `isolated-node-target-clears-old-button` | Node entry delivered | True; no remove buttons remain | +| `omitted-edge-target-rejected` | Only B entry callback omitted | False, with A's model/DOM button still present | +| `omitted-node-target-rejected` | Only node entry callback omitted | False, with A's model/DOM button still present | + +These are explicitly labeled `dropped-notification-fault-injection`, not unmodified natural paths. Each uses a fresh real X6 graph, genuine A entry and real browser movement to B or the node. An adapter wraps only the production helper's subscriptions: it drops earlier edge-leave and blank-hover callbacks, and the negative controls additionally omit only the destination callback. Other delivered callbacks retain their original payload, `this`, context and return value. Independent X6 observers still receive the actual notifications. A bounded event sequence must show A entry, earlier leave and blank hover, then destination entry; both earlier callback types must have been suppressed before that entry. Immediately before the destination callback, A must still have exactly one model/DOM button and vertex tool. Positives clear that old button; negatives must reject that exact clear predicate with A still at one. Missing target events, setup errors, timeouts and absent B tools cannot count as a successful negative control. The same production helper bytes and bundle serve all cases; no source mutation or fabricated graph event is used. + ## Receipt and lifecycle -`--expected-head` and `--output` are required; the output parent directory must already exist. Exit zero requires all nine assertions, zero browser errors and zero external browser requests. Every attempted assertion captures its current fixed subcheck, model/rendered tool counts, cell-existence and pointer-boundary booleans, actual container width/height, and native/X6 event counts before teardown, including on failure. Unattempted assertions retain a null observation. Failure records contain no exception text, console messages, URLs, pointer coordinates, screenshots or traces. Native enter/leave counters accept only trusted mouse PointerEvents whose target is the root container; capture-phase listeners exclude descendant notifications and run independently of X6's mouse-event handlers. Pointer-inside state comes from actual CSS `:hover`, not callback state. The last trusted mouse pointer move is observed on the document in capture phase, with only its geometric/hit-test booleans retained. Native listeners are aborted before each graph reset and on page disposal. The page is served from an ephemeral loopback port and external browser requests are blocked. Browser/context/server cleanup runs in `finally`, and signal handlers close native resources. Playwright actions/assertions have finite timeouts; the hosted wrapper must also bound the complete process. +`--expected-head` and `--output` are required; the output parent directory must already exist. Exit zero requires all nine primary and four supplemental assertions, zero browser errors and zero external browser requests. Every attempted assertion captures its current fixed subcheck, model/rendered tool counts, cell-existence and pointer-boundary booleans, actual container width/height, and native/X6 event counts before teardown, including on failure. Supplemental observations also capture the bounded fixed-event sequence, suppressed callback counts, before/after destination tool counts and the old-A clear predicate. Unattempted assertions retain a null observation. Failure records contain no exception text, console messages, URLs, pointer coordinates, screenshots or browser traces. Native enter/leave counters accept only trusted mouse PointerEvents whose target is the root container; capture-phase listeners exclude descendant notifications and run independently of X6's mouse-event handlers. Pointer-inside state comes from actual CSS `:hover`, not callback state. The last trusted mouse pointer move is observed on the document in capture phase, with only its geometric/hit-test booleans retained. Native listeners are aborted before each graph reset and on page disposal. The page is served from an ephemeral loopback port and external browser requests are blocked. Browser/context/server cleanup runs in `finally`, and signal handlers close native resources. Playwright actions/assertions have finite timeouts; the hosted wrapper must also bound the complete process. The JSON binds the Git source commit, production helper/create-graph/unit-test/Vitest configuration, reviewed and installed ClientLib locks, ClientLib package manifest, source workflow, all fixture source files, and every actual esbuild input to SHA-256 and byte length. It records the exact installed X6/Playwright/esbuild and launched Chromium versions, the JavaScript bundle digest, and native event counts for interactive/noninteractive graphs. The runner requires the expected Git HEAD and clean tracked inputs initially, then rechecks HEAD, tracked cleanliness and every input hash after browser teardown before accepting the result. Eight pure Node guard regressions cover unchanged inputs, mismatched/missing expected head, pre/post tracked edits, restored dependency/lock changes, changed HEAD and untracked source. The receipt is written atomically with mode 0600. Do not accept merely a prior-head receipt or a command that omitted required assertions. diff --git a/test/studio/browser/DesignerHover/fixture.ts b/test/studio/browser/DesignerHover/fixture.ts index 9a0fb9ed3c..c981a3c7c9 100644 --- a/test/studio/browser/DesignerHover/fixture.ts +++ b/test/studio/browser/DesignerHover/fixture.ts @@ -2,18 +2,56 @@ import {Graph} from '@antv/x6'; import '@antv/x6/dist/index.css'; import {registerEdgeHoverTools} from '../../../../src/studio/modules/Elsa.Studio.Workflows.Designer/ClientLib/src/designer/api/edge-hover-tools'; -// Expose observations and fresh-graph setup only. All acceptance interactions come from Chromium's mouse. +// All interactions come from Chromium's mouse. Supplemental probes explicitly drop selected helper +// notifications; the nine primary cases subscribe directly, without this fault-injection adapter. const container = document.getElementById('graph')!; let graph: Graph; let events: Record; let lastTrustedPointer: {x: number; y: number} | null = null; let observationController: AbortController; +type ProbeOptions = {target: 'edge' | 'node'; omitTarget: boolean}; +type OldEdgeState = {model_buttons: number; rendered_buttons: number; model_vertices: number; rendered_vertices: number}; +let probe: (ProbeOptions & { + event_order: string[]; + order_overflow: boolean; + a_event_index: number; + suppressed_edge_leaves: number; + suppressed_blank_overs: number; + target_callbacks: number; + target_entry: {event_index: number; before: OldEdgeState; after: OldEdgeState; delivered: boolean; + suppressed_edge_leaves: number; suppressed_blank_overs: number} | null; +}) | null; -function reset(enabled: boolean) { +function edgeTools(id: string) { + const edge = graph.getCellById(id); + const items = edge?.getTools()?.items ?? []; + const names = items.map(item => typeof item === 'string' ? item : item.name); + return { + id, + exists: Boolean(edge), + buttons: names.filter(name => name === 'button-remove').length, + vertices: names.filter(name => name === 'vertices').length, + }; +} + +function oldEdgeState(): OldEdgeState { + const edge = edgeTools('edge-a'); + const tools = '.x6-cell-tools[data-cell-id="edge-a"]'; + return { + model_buttons: edge.buttons, + rendered_buttons: container.querySelectorAll(`${tools} [data-tool-name="button-remove"]`).length, + model_vertices: edge.vertices, + rendered_vertices: container.querySelectorAll(`${tools} [data-tool-name="vertices"]`).length, + }; +} + +function reset(enabled: boolean, options: ProbeOptions | null = null) { observationController?.abort(); graph?.dispose(); events = {}; lastTrustedPointer = null; + probe = options ? {...options, event_order: [], order_overflow: false, a_event_index: -1, + suppressed_edge_leaves: 0, suppressed_blank_overs: 0, target_callbacks: 0, target_entry: null} : null; graph = new Graph({ container, width: 800, @@ -22,13 +60,65 @@ function reset(enabled: boolean) { interacting: enabled, background: {color: '#fafafa'}, }); - registerEdgeHoverTools(graph, enabled); + // Observe real X6 notifications before helper callbacks, including callbacks omitted by probes. for (const event of ['edge:mouseenter', 'edge:mouseleave', 'node:mouseenter', 'blank:mouseover', 'graph:mouseleave'] as const) { graph.on(event, (args: {edge?: {id: string}}) => { const key = args.edge ? `${event}:${args.edge.id}` : event; events[key] = (events[key] ?? 0) + 1; + if (probe) { + if (probe.event_order.length < 64) { + probe.event_order.push(key); + } else { + probe.order_overflow = true; + } + } }); } + if (probe) { + const on: Graph['on'] = (name: string, handler: Parameters[1], context?: unknown) => + graph.on(name, (...args: Parameters) => { + const state = probe!; + if (name === 'edge:mouseleave' || name === 'blank:mouseover') { + // Drop only the helper's earlier fallback callbacks, retaining independent events. + if (state.a_event_index >= 0) { + if (name === 'edge:mouseleave') { + state.suppressed_edge_leaves += 1; + } else { + state.suppressed_blank_overs += 1; + } + } + return; + } + const edgeId = args[0]?.edge?.id; + const target = state.target === 'edge' + ? name === 'edge:mouseenter' && edgeId === 'edge-b' + : name === 'node:mouseenter'; + if (target) { + state.target_callbacks += 1; + const before = oldEdgeState(); + const event_index = state.event_order.length - 1; + const suppressed_edge_leaves = state.suppressed_edge_leaves; + const suppressed_blank_overs = state.suppressed_blank_overs; + // Negative controls omit only the destination callback. A's genuine entry is delivered. + // X6 invokes handlers with their registered context. Forward that exact context. + const result = state.omitTarget ? undefined : handler.apply(context, args); + state.target_entry ??= {event_index, before, after: oldEdgeState(), delivered: !state.omitTarget, + suppressed_edge_leaves, suppressed_blank_overs}; + return result; + } + const eventIndex = state.event_order.length - 1; + const result = handler.apply(context, args); + if (name === 'edge:mouseenter' && edgeId === 'edge-a') { + state.a_event_index = eventIndex; + state.suppressed_edge_leaves = 0; + state.suppressed_blank_overs = 0; + } + return result; + }, context); + registerEdgeHoverTools({on}, enabled); + } else { + registerEdgeHoverTools(graph, enabled); + } // X6 handles mouse/touch events. Observe untouched trusted pointer events at the actual root // in capture phase, independently of its emulated mouseenter/leave and descendant events. observationController = new AbortController(); @@ -64,17 +154,8 @@ function snapshot() { trustedPointerSeen: point !== null, trustedPointerInsideRect: point !== null && point.x >= rect.left && point.x < rect.right && point.y >= rect.top && point.y < rect.bottom, trustedPointerHitsGraph: hit !== null && container.contains(hit), - edges: ['edge-a', 'edge-b'].map(id => { - const edge = graph.getCellById(id); - const items = edge?.getTools()?.items ?? []; - const names = items.map(item => typeof item === 'string' ? item : item.name); - return { - id, - exists: Boolean(edge), - buttons: names.filter(name => name === 'button-remove').length, - vertices: names.filter(name => name === 'vertices').length, - }; - }), + edges: ['edge-a', 'edge-b'].map(edgeTools), + probe, }; } diff --git a/test/studio/browser/DesignerHover/run.mjs b/test/studio/browser/DesignerHover/run.mjs index 7e9af80ab7..2a85c3c578 100644 --- a/test/studio/browser/DesignerHover/run.mjs +++ b/test/studio/browser/DesignerHover/run.mjs @@ -20,6 +20,13 @@ const assertions = [ 'node-clears-hover', 'blank-clears-hover', 'outside-clears-hover', 'remove-tool-entry', 'remove-tool-click', 'noninteractive-no-tools', ]; +const supplemental = [ + {name: 'isolated-edge-target-clears-old-button', target: 'edge', omitTarget: false}, + {name: 'isolated-node-target-clears-old-button', target: 'node', omitTarget: false}, + {name: 'omitted-edge-target-rejected', target: 'edge', omitTarget: true}, + {name: 'omitted-node-target-rejected', target: 'node', omitTarget: true}, +]; +const resultRecords = names => names.map(name => ({name, passed: false, reason_category: 'not_run', observation: null})); const receipt = { schema: 1, kind: 'designer-hover-source-browser', @@ -29,7 +36,9 @@ const receipt = { dependencies: null, browser: null, bundle_sha256: null, - assertions: assertions.map(name => ({name, passed: false, reason_category: 'not_run', observation: null})), + assertions: resultRecords(assertions), + supplemental_assertions: resultRecords(supplemental.map(probe => probe.name)) + .map(result => ({...result, mode: 'dropped-notification-fault-injection'})), events: null, browser_errors: 0, blocked_requests: 0, @@ -218,12 +227,15 @@ try { x6_graph_leaves: state.events['graph:mouseleave'] ?? 0, x6_node_enters: state.events['node:mouseenter'] ?? 0, x6_blank_overs: state.events['blank:mouseover'] ?? 0, + fault_injection: state.probe ? {...state.probe, + old_a_clear_predicate: state.probe.target_entry === null ? null + : state.probe.target_entry.after.model_buttons === 0 && state.probe.target_entry.after.rendered_buttons === 0} : null, }; }; - const run = async (name, action) => { + const run = async (name, action, results = receipt.assertions) => { stage = name; subcheck = 'action'; - const result = receipt.assertions.find(result => result.name === name); + const result = results.find(result => result.name === name); result.reason_category = 'assertion_failed'; try { await action(); @@ -335,8 +347,57 @@ try { expect((await snapshot()).pointerInside).toBe(false); }); receipt.events = {interactive: interactiveEvents, noninteractive: (await snapshot()).events}; + + // These supplemental cases explicitly drop the helper's earlier leave/blank callbacks. They + // isolate target fallback handling without claiming an unmodified natural event sequence. + for (const {name, target, omitTarget} of supplemental) { + await run(name, async () => { + await move(850, 480); + await page.evaluate(options => window.hoverFixture.reset(true, options), {target, omitTarget}); + // Approach A from above so no destination edge/node entry can precede A's real entry. + await move(320, -20); + await hover('edge-a'); + subcheck = 'source-entry-delivered'; + const source = (await snapshot()).probe; + expect(source.target_callbacks).toBe(0); + expect(source.a_event_index).toBeGreaterThanOrEqual(0); + + await move(...(target === 'edge' ? edgePoint('edge-b') : [740, 120])); + subcheck = 'real-destination-entry'; + const state = (await snapshot()).probe; + expect(state.order_overflow).toBe(false); + expect(state.target_callbacks).toBe(1); + expect(state.target_entry).not.toBeNull(); + const entry = state.target_entry; + const targetEvent = target === 'edge' ? 'edge:mouseenter:edge-b' : 'node:mouseenter'; + expect(state.event_order[state.a_event_index]).toBe('edge:mouseenter:edge-a'); + expect(state.event_order[entry.event_index]).toBe(targetEvent); + expect(entry.event_index).toBeGreaterThan(state.a_event_index); + expect(await eventCount(targetEvent)).toBe(1); + subcheck = 'earlier-callbacks-filtered'; + const earlierEvents = state.event_order.slice(state.a_event_index + 1, entry.event_index); + expect(earlierEvents).toContain('edge:mouseleave:edge-a'); + expect(earlierEvents).toContain('blank:mouseover'); + expect(entry.suppressed_edge_leaves).toBeGreaterThan(0); + expect(entry.suppressed_blank_overs).toBeGreaterThan(0); + subcheck = 'old-button-present-before-destination'; + expect(entry.before).toEqual({model_buttons: 1, rendered_buttons: 1, model_vertices: 1, rendered_vertices: 1}); + expect(entry.delivered).toBe(!omitTarget); + subcheck = omitTarget ? 'omitted-target-clear-predicate-rejected' : 'delivered-target-clear-predicate'; + const oldButtonCleared = entry.after.model_buttons === 0 && entry.after.rendered_buttons === 0; + // The negative control must fail this exact old-A-clear predicate, not a B-tool or setup guard. + expect(oldButtonCleared).toBe(!omitTarget); + expect(entry.after.model_vertices).toBe(1); + expect(entry.after.rendered_vertices).toBe(1); + await checkEdge('edge-a', omitTarget ? 1 : 0); + if (!omitTarget) { + await checkEdge('edge-b', target === 'edge' ? 1 : 0, target === 'edge' ? 1 : 0); + await expect(allButtons).toHaveCount(target === 'edge' ? 1 : 0); + } + }, receipt.supplemental_assertions); + } expect(interrupted).toBe(false); - receipt.passed = receipt.assertions.every(result => result.passed); + receipt.passed = [...receipt.assertions, ...receipt.supplemental_assertions].every(result => result.passed); } catch { receipt.failure_category = interrupted ? 'interrupted' : stage; } finally { From 68d0e48d1eca94c4bfe2f8d3db5ec3d70159d980 Mon Sep 17 00:00:00 2001 From: Sipke Schoorstra Date: Thu, 8 Oct 2026 00:48:32 +0200 Subject: [PATCH 9/9] test(studio): approach isolated hover probes outside graph --- test/studio/browser/DesignerHover/run.mjs | 1 + 1 file changed, 1 insertion(+) diff --git a/test/studio/browser/DesignerHover/run.mjs b/test/studio/browser/DesignerHover/run.mjs index 2a85c3c578..06823eda39 100644 --- a/test/studio/browser/DesignerHover/run.mjs +++ b/test/studio/browser/DesignerHover/run.mjs @@ -355,6 +355,7 @@ try { await move(850, 480); await page.evaluate(options => window.hoverFixture.reset(true, options), {target, omitTarget}); // Approach A from above so no destination edge/node entry can precede A's real entry. + await move(850, -20); await move(320, -20); await hover('edge-a'); subcheck = 'source-entry-delivered';