Skip to content

Commit 278d244

Browse files
fix(app-shell): a node click on a read-only flow canvas selects the node and opens the inspector read-only (objectui#11546) (#11603)
Fixes #11546 Clause-②: no On a read-only package, a click on a flow canvas node in Studio → Automations now selects the node and opens the flow inspector read-only. Only the drag is withheld. No editable input appears. ## Mechanism (measured in Chromium before the fix) `FlowCanvas`'s `onNodePointerDown` returned early on a read-only canvas (`if (!editable || e.button !== 0) return;`) before `e.stopPropagation()`. The press therefore reached the viewport's `onBgPointerDown`, which calls `onSelect(null)` and takes pointer capture on the viewport. A browser sends the pointerup and the click to the capturing element, so the click landed on the viewport and the node card's `onClick` (its select) never ran. A scratch harness (untracked, deleted after use) mounted the registered `FlowPreview` exactly as the Automations pillar mounts it on a read-only package (`editing: true`, `onSelectionChange`, no `onPatch`), with the registered `FlowInspector` given `readOnly: true`. It was driven in Chromium (`/opt/pw-browsers/chromium`) at base `2e818d0` and again with the fix: | reading | base `2e818d0` | with the fix | |---|---|---| | read-only node click (`page.click`) | selection `none`, inspector absent | `node:find`, 7 inputs, 0 enabled | | read-only node click (`mouse.down` / `mouse.up`) | selection `none` | `node:find` | | event targets after a read-only node press | pointerdown on the node card, `gotpointercapture` / pointerup / click on the viewport (`role=application`) | pointerdown, pointerup and click on the node card | | read-only press-and-drag on a node | not moved | not moved | | read-only edge click | `edge:e_start_find`, 3 inputs, 0 enabled | same | | editable node click / drag (control) | `node:find`, 7 inputs, 7 enabled / moved | same | ## Change `FlowCanvas` `onNodePointerDown`: in design mode a node press stops propagation whether or not the canvas is editable, and only the drag start stays gated on `editable`. The Delete-key handler is untouched and still gated on `editable`. Outside design mode, where a node click selects nothing, a node press still falls through to the background pan, as before. `StudioDesignSurface.tsx` is unchanged. The selection already opened the inspector with the pillar's real `readOnly` flag (objectui#11124's path). It was the selection that never happened. **Edges are a different mechanism and needed nothing.** The edge hit-path and its branch-label pill stop propagation on pointer-down unconditionally whenever the edge is selectable (design mode with `onSelectEdge`). The table above shows a read-only edge click selecting the edge at base already. Nested region-tray nodes also stop propagation unconditionally. ## Pins - New `packages/app-shell/src/views/studio-design/StudioDesignSurface.automationsReadOnlySelect-11546.test.tsx`, with the real `AutomationsPillar`, `FlowPreview` and `FlowInspector`: - "selects the clicked node and opens its inspector, with every input disabled": a read-only package; the rail's Label and ID are disabled, and no input, textarea, select, contenteditable, combobox, switch or checkbox in the rail is enabled. No save happens. - "the same click opens the inspector with editable inputs": the writable control. It also shows that the read-only test's "no enabled control" reading can fail. - `FlowCanvas.test.tsx`, a new describe block for objectui#11546: a read-only click selects that node and never calls `onSelect(null)`; a read-only drag captures nothing, writes nothing and pans nothing; the editable designer (the control) still selects on click and commits `position` on drag; outside design mode a node press still pans. - New helper `packages/app-shell/src/views/metadata-admin/previews/__tests__/browserClick.ts` (`browserClick`, `browserDrag`). It sends the pointerup and the click to the element holding pointer capture, then releases capture, as a browser does. happy-dom records `setPointerCapture` but routes no event by it. That routing gap is why objectui#11124's pins stayed green on this defect: they send `fireEvent.click` straight to the node card, with no press. The helper sits under `__tests__/`, which app-shell's `tsconfig.json` excludes from the build. The dist check below confirms nothing of it ships. - objectui#11124's read-only-inputs pin, "opens the flow inspector read-only: the node inputs are disabled" in `StudioDesignSurface.automationsReadOnly-11124.test.tsx`, is untouched and green, together with the rest of that file. ## Ablation (fix committed first; the subject resolves from `src`, so no build sits on the path) The ablation used `node /home/user/objectstack/scripts/ablation-replace.mjs` under the verify lock. The anchor was the three fixed lines (`if (!editable && !designMode) return;` / `e.stopPropagation();` / `if (!editable) return;`). They were replaced by the base order (`if (!editable) return;` / `e.stopPropagation();`). The tool reported: anchor hits 1 → 0, blob `82501d693e1d` → `95126d94e35d`. - Expected direction: the read-only pins turn red and the controls stay green. **Observed: that direction.** `Tests 3 failed | 29 passed (32)` over the new pin file, `FlowCanvas.test.tsx` and the objectui#11124 file. The 3 red were the host read-only pin (the inspector's Label never appears), the canvas read-only click (`expected [ null ] to deeply equal [ 'b' ]`) and the canvas read-only drag (the viewport captured the press). Every objectui#11124 pin stayed green under the defect. - Restore: `git checkout HEAD -- PATH` was run with PATH absolute. Blob after the restore = HEAD blob `82501d693e1d58384729c3cf61f9c29284399a98`, and `git diff HEAD` is empty. ## Gates (all at `c47b708`) | command | exit | verdict line | |---|---|---| | `pnpm --workspace-concurrency=2 --filter '@object-ui/app-shell^...' build` (lock) | 0 | `Scope: 29 of 47 workspace projects`, `VERDICT command-exit 0` | | `pnpm --filter @object-ui/app-shell type-check` (`tsc --noEmit && tsc -p tsconfig.test.json`) (lock) | 0 | silent tsc; `--listFilesOnly` on `tsconfig.test.json` lists the helper and both pin files (3) | | `pnpm exec vitest run --maxWorkers=2 packages/app-shell/src/views/metadata-admin/ packages/app-shell/src/views/studio-design/` + `packages/app-shell/src/__tests__/spec-symbol-parity.test.ts`, `apps/console/src/__tests__/registry-inputs-spec-parity.test.ts`, `scripts/__tests__/{markdown-test-inputs,one-authority-per-exported-name-6273,check-lucide-icon-record-names}.test.ts` (lock) | 0 | `Test Files 472 passed (472)`, `Tests 5352 passed / 1 skipped (5353)` | | `pnpm --filter @object-ui/app-shell build`, then a `dist/` probe (lock) | 0 | `FlowCanvas.js` 1 (control, carries the fix); `browserClick*` 0; `__tests__` paths 0; `*11546*` 0 | | `node scripts/check-control-bytes.mjs` | 0 | `OK (scanned 7497 tracked text file(s)…)` | | `node scripts/check-new-cross-file-line-citations.mjs` | 0 | `0 new citation(s)` | | `node scripts/check-changeset-presence.mjs` | 0 | `1 changeset(s) added` | | `node scripts/check-changeset-claims.mjs` | 0 | `No pending changeset names a file this change touches.` | | `node scripts/check-pending-changeset-literals.mjs` | 0 | `No test source names a pending changeset.` | | `node scripts/check-changeset-no-major.mjs` / `check-changeset-overwrite.mjs` | 0 / 0 | `No changeset declares a major bump.` / `No pre-existing changeset was modified or deleted.` | | `node scripts/check-vi-mock-specifiers.mjs` / `check-vi-mock-inherit.mjs` / `check-vi-mock-override-shape.mjs` | 0 / 0 / 0 | `OK` each | | `node scripts/check-test-path-roots.mjs` | 0 | `OK` | | `node scripts/check-phantom-dependencies.mjs` / `check-published-tsconfig-tooling-exclude.mjs` | 0 / 0 | `Every in-scope i…` / `all 34 enforced package(s) carry the directory form` | **Test selection, narrowed:** both touched directories in full, plus every test that imports or reads as source text a changed file or `FlowCanvas`. The selection was made by `git grep` for `FlowCanvas` / `flow-canvas-parts` / `browserClick` over test files, and by `readFileSync` / `?raw` readers that name the previews path. The rest of app-shell and the whole-repo `pnpm test` are declared to CI. **Lint, a proven narrowing (not `pnpm lint`):** `npx eslint --format json` was run from `packages/app-shell`, as its `eslint .` would be. 1. Population: the diff's four `.ts`/`.tsx` files. eslint's own config answers the changeset `.md` with "File ignored because no matching configuration was supplied." 2. Count: the JSON holds 4 results, 0 errors and 1 warning. The warning is `react-hooks/exhaustive-deps` on `addNode`'s `useCallback`. The base file linted identically shows the same warning at the same place, so it is not this diff's. 3. Invariance: `eslint.config.js` sets no `parserOptions.project` / `projectService`, so the linting is not type-aware. No rule under `eslint-rules/` reads another file (zero `readFileSync` / `readdirSync` / `globSync` hits). This diff therefore cannot move the verdict on an untouched file. NOT MEASURED: `pnpm check:published-dist`, reason: it builds the whole workspace (CI's run). The narrowed `dist/` probe above covers this diff's helper. NOT MEASURED: full `pnpm test` / `pnpm lint`, reason: repository-wide runs owned by CI. ## Acceptance notes - The `react-hooks/exhaustive-deps` warning on `addNode` (an unnecessary `positionOf` dependency) predates this branch and is left alone. It is out of scope and not a defect. - One-off evidence (the Chromium harness, the driver, the ablation) left no file in the tree. Session: `https://claude.ai/code/session_01FjqrwXPfSMkSfkKYDSRkN2` --- _Generated by [Claude Code](https://claude.ai/code/session_01FjqrwXPfSMkSfkKYDSRkN2)_ Co-authored-by: Claude <noreply@anthropic.com>
1 parent b61c116 commit 278d244

5 files changed

Lines changed: 349 additions & 2 deletions

File tree

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
---
2+
'@object-ui/app-shell': patch
3+
---
4+
5+
On a read-only package, a click on a flow canvas node in Studio Automations selects the node and opens its inspector read-only again (objectui#11546).
6+
7+
On a read-only canvas the node's press stopped short of claiming the event, so it reached the
8+
canvas background. The background cleared the selection and took the pointer, and the browser
9+
then fired the click at the canvas instead of the node, so the inspector rail kept its empty
10+
state and a packaged flow's node configuration could not be read.
11+
12+
A press on a node in the designer now belongs to the node on a read-only canvas too. The click
13+
selects the node and the inspector opens with every input disabled, as objectui#11124 set out.
14+
Only the drag is withheld: a press-and-drag on a read-only node moves nothing and writes nothing.
15+
The editable designer drags and selects as before, and the Delete key still deletes only on an
16+
editable canvas. Outside design mode, where a node click selects nothing, a press on a node still
17+
pans the canvas.

‎packages/app-shell/src/views/metadata-admin/previews/FlowCanvas.test.tsx‎

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { FlowCanvas } from './FlowCanvas';
77
import { extractRegions, NODE_H } from './flow-canvas-layout';
88
import { predictExpandedNodeHeight } from './flow-region-metrics';
99
import type { FlowProblem } from './flow-problems';
10+
import { browserClick, browserDrag } from './__tests__/browserClick';
1011

1112
afterEach(cleanup);
1213

@@ -499,3 +500,89 @@ describe('FlowCanvas — geometry writes are spec-canonical `position` (#3172)',
499500
expectNoLegacyKey(nodes);
500501
});
501502
});
503+
504+
/**
505+
* objectui#11546 — on a read-only design canvas a node click selects the node;
506+
* only the drag is withheld. Measured in Chromium before the fix: the node's
507+
* pointer-down returned before stopping propagation, the background cleared
508+
* the selection and captured the pointer, and the click landed on the
509+
* viewport. The presses below are routed the way a browser routes them
510+
* (`browserClick` / `browserDrag`), since happy-dom routes nothing by capture.
511+
*/
512+
describe('FlowCanvas — a node press on a read-only design canvas selects, and only the drag is withheld (objectui#11546)', () => {
513+
const NODES = [
514+
{ id: 'a', type: 'start', label: 'Start' },
515+
{ id: 'b', type: 'script', label: 'Do the thing' },
516+
];
517+
const EDGES = [{ source: 'a', target: 'b' }];
518+
519+
const renderCanvas = ({
520+
editable,
521+
designMode,
522+
onSelect = vi.fn(),
523+
onPatch = vi.fn(),
524+
}: {
525+
editable: boolean;
526+
designMode: boolean;
527+
onSelect?: Mock<React.ComponentProps<typeof FlowCanvas>['onSelect']>;
528+
onPatch?: Mock<NonNullable<React.ComponentProps<typeof FlowCanvas>['onPatch']>>;
529+
}) => {
530+
const utils = render(
531+
<FlowCanvas
532+
nodes={NODES}
533+
edges={EDGES}
534+
editable={editable}
535+
designMode={designMode}
536+
selectedId={null}
537+
onSelect={onSelect}
538+
onPatch={onPatch}
539+
/>,
540+
);
541+
const card = (id: string) => {
542+
const el = utils.container.querySelector(`[data-node-id="${id}"] [role="button"]`) as HTMLElement | null;
543+
expect(el, `the canvas must render node ${id}`).not.toBeNull();
544+
return el!;
545+
};
546+
const at = (id: string) => {
547+
const el = utils.container.querySelector(`[data-node-id="${id}"]`) as HTMLElement;
548+
return `${el.style.left},${el.style.top}`;
549+
};
550+
const viewport = screen.getByRole('application', { name: 'Flow canvas' });
551+
const panTransform = () => (viewport.firstElementChild as HTMLElement).style.transform;
552+
return { onSelect, onPatch, card, at, viewport, panTransform };
553+
};
554+
555+
it('read-only: a click on a node selects that node and never clears the selection', () => {
556+
const { onSelect, card } = renderCanvas({ editable: false, designMode: true });
557+
browserClick(card('b'));
558+
expect(onSelect.mock.calls.map(([n]) => n?.id ?? null)).toEqual(['b']);
559+
});
560+
561+
it('read-only: a drag on a node moves nothing, writes nothing and pans nothing', () => {
562+
const { onPatch, card, at, panTransform } = renderCanvas({ editable: false, designMode: true });
563+
const before = { node: at('b'), pan: panTransform() };
564+
const captor = browserDrag(card('b'), 60, 60);
565+
expect(captor).toBeNull();
566+
expect(onPatch).not.toHaveBeenCalled();
567+
expect({ node: at('b'), pan: panTransform() }).toEqual(before);
568+
});
569+
570+
it('editable designer (the control): a click selects, and a drag still moves the node', () => {
571+
const { onSelect, onPatch, card } = renderCanvas({ editable: true, designMode: true });
572+
browserClick(card('b'));
573+
expect(onSelect.mock.calls.map(([n]) => n?.id ?? null)).toEqual(['b']);
574+
575+
expect(browserDrag(card('b'), 60, 60)).toBe(card('b'));
576+
expect(onPatch).toHaveBeenCalledTimes(1);
577+
const moved = (onPatch.mock.calls[0][0] as { nodes: Array<{ id: string; position?: unknown }> }).nodes.find((n) => n.id === 'b');
578+
expect(moved?.position).toBeDefined();
579+
});
580+
581+
it('outside design mode a press on a node, which selects nothing, still pans the canvas', () => {
582+
const { onPatch, card, viewport, panTransform } = renderCanvas({ editable: false, designMode: false });
583+
const before = panTransform();
584+
expect(browserDrag(card('b'), 60, 60)).toBe(viewport);
585+
expect(panTransform()).not.toBe(before);
586+
expect(onPatch).not.toHaveBeenCalled();
587+
});
588+
});

‎packages/app-shell/src/views/metadata-admin/previews/FlowCanvas.tsx‎

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -407,8 +407,17 @@ export function FlowCanvas({
407407

408408
const onNodePointerDown = React.useCallback(
409409
(id: string) => (e: React.PointerEvent) => {
410-
if (!editable || e.button !== 0) return;
410+
if (e.button !== 0) return;
411+
// objectui#11546 — a press on a node that answers it (a drag when
412+
// editable, a select in design mode) is the node's, never the
413+
// background's. Reaching `onBgPointerDown` clears the selection and takes
414+
// pointer capture on the viewport, so the browser fires the click at the
415+
// viewport and the node's own select never runs. A read-only design
416+
// canvas withholds only the drag; a press on a node that answers neither
417+
// still pans.
418+
if (!editable && !designMode) return;
411419
e.stopPropagation();
420+
if (!editable) return;
412421
const origin = positionOf(id);
413422
dragRef.current = {
414423
nodeId: id,
@@ -420,7 +429,7 @@ export function FlowCanvas({
420429
};
421430
(e.currentTarget as HTMLElement).setPointerCapture?.(e.pointerId);
422431
},
423-
[editable, positionOf],
432+
[designMode, editable, positionOf],
424433
);
425434

426435
const onNodePointerMove = React.useCallback(
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import { fireEvent } from '@testing-library/react';
4+
5+
const POINTER_ID = 1;
6+
7+
/**
8+
* A primary-button click dispatched the way a browser dispatches it
9+
* (objectui#11546).
10+
*
11+
* `fireEvent.click` alone skips the press, and happy-dom records
12+
* `setPointerCapture` without routing any event by it. A browser sends the
13+
* pointerup and the click to the element that took pointer capture during the
14+
* press, then releases the capture. That routing is what hid objectui#11546
15+
* from the flow canvas pins: in Chromium, the canvas background captured a
16+
* node press on a read-only canvas, the click landed on the viewport, and the
17+
* node's own select never ran. This helper does that routing, so a press that
18+
* leaks to the background fails the pin the way it fails in a browser.
19+
*/
20+
export function browserClick(target: HTMLElement): void {
21+
const at = { button: 0, pointerId: POINTER_ID, clientX: 10, clientY: 10 };
22+
fireEvent.pointerDown(target, at);
23+
const captor = capturing() ?? target;
24+
fireEvent.pointerUp(captor, at);
25+
fireEvent.click(captor, { button: 0 });
26+
releaseAll();
27+
}
28+
29+
/**
30+
* A primary-button press that moves before it is released, routed the same
31+
* way. Returns the element that held pointer capture during the move, or null
32+
* when nothing captured the press.
33+
*/
34+
export function browserDrag(target: HTMLElement, dx: number, dy: number): HTMLElement | null {
35+
fireEvent.pointerDown(target, { button: 0, pointerId: POINTER_ID, clientX: 0, clientY: 0 });
36+
const captor = capturing();
37+
const routed = captor ?? target;
38+
fireEvent.pointerMove(routed, { pointerId: POINTER_ID, clientX: dx, clientY: dy });
39+
fireEvent.pointerUp(routed, { button: 0, pointerId: POINTER_ID, clientX: dx, clientY: dy });
40+
releaseAll();
41+
return captor;
42+
}
43+
44+
function capturing(): HTMLElement | null {
45+
return Array.from(document.body.querySelectorAll<HTMLElement>('*')).find((el) => el.hasPointerCapture?.(POINTER_ID)) ?? null;
46+
}
47+
48+
/** A browser releases capture implicitly after pointerup; happy-dom does not. */
49+
function releaseAll(): void {
50+
for (const el of Array.from(document.body.querySelectorAll<HTMLElement>('*'))) {
51+
if (el.hasPointerCapture?.(POINTER_ID)) el.releasePointerCapture(POINTER_ID);
52+
}
53+
}
Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,181 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* objectui#11546 — on a read-only package, a click on a flow canvas node in the
5+
* Automations pillar selects it and opens the flow inspector read-only.
6+
*
7+
* Measured in Chromium before the fix: the node's pointer-down returned early
8+
* on a read-only canvas, before stopping propagation, so the press reached the
9+
* canvas background. The background cleared the selection and took pointer
10+
* capture on the viewport, so the browser fired the click at the viewport and
11+
* the node's own select never ran. The inspector rail kept its empty state.
12+
*
13+
* objectui#11124's pins never saw it: they send `fireEvent.click` straight to
14+
* the node, with no press first, and happy-dom honours no pointer capture. The
15+
* click below is dispatched the way a browser dispatches it (`browserClick`),
16+
* so the press and the capture are part of what is measured.
17+
*
18+
* The canvas and the inspector are the REAL registered `FlowPreview` and
19+
* `FlowInspector`. The writable control is in the same file.
20+
*/
21+
22+
import '@testing-library/jest-dom/vitest';
23+
import * as React from 'react';
24+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
25+
import { render, screen, cleanup, waitFor, within } from '@testing-library/react';
26+
import { MemoryRouter } from 'react-router-dom';
27+
28+
const PKG = 'com.acme.app';
29+
30+
const FLOW = {
31+
name: 'notify_owner',
32+
label: 'Notify owner',
33+
type: 'autolaunched',
34+
status: 'active',
35+
nodes: [
36+
{ id: 'start', type: 'start', label: 'Start' },
37+
{ id: 'end', type: 'end', label: 'End' },
38+
],
39+
edges: [{ id: 'e1', source: 'start', target: 'end' }],
40+
};
41+
42+
const server = vi.hoisted(() => ({
43+
active: new Map<string, Record<string, unknown>>(),
44+
saves: [] as Array<{ type: string; name: string }>,
45+
}));
46+
47+
const mockClient = vi.hoisted(() => {
48+
const k = (type: string, name: string) => `${type}/${name}`;
49+
return {
50+
list: vi.fn(async (type: string) =>
51+
[...server.active.entries()]
52+
.filter(([key]) => key.startsWith(`${type}/`))
53+
.map(([, row]) => ({ name: row.name, label: row.label ?? row.name })),
54+
),
55+
listDrafts: vi.fn(async () => []),
56+
listTypes: vi.fn(async () => ({ entries: [] })),
57+
get: vi.fn(async () => null),
58+
references: vi.fn(async () => []),
59+
layered: vi.fn(async (type: string, name: string) => {
60+
const eff = server.active.get(k(type, name)) ?? null;
61+
return { code: null, overlay: eff, overlayScope: eff ? 'env' : null, effective: eff, editable: true, deletable: true, resettable: false, lock: 'none' };
62+
}),
63+
getDraft: vi.fn(async (type: string, name: string) => {
64+
throw Object.assign(new Error(`No pending draft exists for ${type}/${name}.`), { code: 'NO_DRAFT', status: 404 });
65+
}),
66+
save: vi.fn(async (type: string, name: string, item: unknown) => {
67+
server.saves.push({ type, name });
68+
return { type, name, item };
69+
}),
70+
publish: vi.fn(async () => ({ success: true })),
71+
reset: vi.fn(async () => ({})),
72+
};
73+
});
74+
75+
vi.mock('../metadata-admin/useMetadata', async (importOriginal) => {
76+
const mod = await importOriginal<typeof import('../metadata-admin/useMetadata')>();
77+
return { ...mod, useMetadataClient: () => mockClient, useMetadataTypes: () => ({ entries: [] }) };
78+
});
79+
80+
vi.mock('./packages-io', async (importOriginal) => {
81+
const mod = await importOriginal<typeof import('./packages-io')>();
82+
return { ...mod, fetchPackages: vi.fn(async () => []) };
83+
});
84+
85+
vi.mock('@object-ui/react', async (importOriginal) => {
86+
const mod = await importOriginal<typeof import('@object-ui/react')>();
87+
return { ...mod, useAdapter: () => dataSource };
88+
});
89+
90+
vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
91+
92+
import { AutomationsPillar } from './StudioDesignSurface';
93+
import { createEmptyDataSource, failOnAbsorbedFetchError } from './__tests__/emptyDataSource';
94+
import { registerMetadataPreview } from '../metadata-admin/preview-registry';
95+
import { registerMetadataInspector } from '../metadata-admin/inspector-registry';
96+
import { FlowPreview } from '../metadata-admin/previews/FlowPreview';
97+
import { FlowInspector } from '../metadata-admin/inspectors/FlowInspector';
98+
import { browserClick } from '../metadata-admin/previews/__tests__/browserClick';
99+
100+
const dataSource = createEmptyDataSource();
101+
failOnAbsorbedFetchError();
102+
103+
// The pillar's `/automation/_status` probe and the inspector's action-catalog
104+
// read both go through the global `fetch`; one module-scope double answers
105+
// "absent" so each keeps its documented fallback.
106+
vi.stubGlobal(
107+
'fetch',
108+
vi.fn(async () => new Response('null', { status: 404, headers: { 'content-type': 'application/json' } })),
109+
);
110+
111+
registerMetadataPreview('flow', FlowPreview);
112+
registerMetadataInspector('flow', FlowInspector);
113+
114+
beforeEach(() => {
115+
server.active.clear();
116+
server.saves.length = 0;
117+
for (const fn of Object.values(mockClient)) (fn as unknown as { mockClear: () => void }).mockClear();
118+
server.active.set(`flow/${FLOW.name}`, JSON.parse(JSON.stringify(FLOW)));
119+
});
120+
121+
afterEach(cleanup);
122+
123+
function renderPillar(readOnly: boolean) {
124+
return render(
125+
<MemoryRouter initialEntries={[`/studio/${PKG}/automations`]}>
126+
<AutomationsPillar packageId={PKG} readOnly={readOnly} />
127+
</MemoryRouter>,
128+
);
129+
}
130+
131+
async function openFlow(): Promise<void> {
132+
await waitFor(() => expect(screen.getByText('Status:').nextElementSibling?.textContent).toBe('active'), { timeout: 8000 });
133+
}
134+
135+
function startNodeCard(): HTMLElement {
136+
const card = document.querySelector('[data-node-id="start"] [role="button"]') as HTMLElement | null;
137+
expect(card, 'the canvas must render the start node').not.toBeNull();
138+
return card!;
139+
}
140+
141+
/** The controls in the rail an author could type into or toggle, and that are not disabled. */
142+
function enabledRailControls(rail: HTMLElement): HTMLElement[] {
143+
return Array.from(
144+
rail.querySelectorAll<HTMLElement>(
145+
'input, textarea, select, [contenteditable="true"], [role="combobox"], [role="switch"], [role="checkbox"]',
146+
),
147+
).filter((el) => !el.matches(':disabled') && el.getAttribute('aria-disabled') !== 'true');
148+
}
149+
150+
describe('Automations pillar on a read-only package: a node click opens the inspector read-only (objectui#11546)', () => {
151+
it('selects the clicked node and opens its inspector, with every input disabled', async () => {
152+
renderPillar(true);
153+
await openFlow();
154+
155+
browserClick(startNodeCard());
156+
157+
const rail = screen.getByRole('complementary');
158+
const label = await within(rail).findByLabelText('Label', undefined, { timeout: 8000 });
159+
expect(startNodeCard()).toHaveAttribute('aria-pressed', 'true');
160+
expect(label).toBeDisabled();
161+
expect(within(rail).getByLabelText('ID')).toBeDisabled();
162+
expect(enabledRailControls(rail)).toEqual([]);
163+
expect(server.saves).toHaveLength(0);
164+
});
165+
});
166+
167+
describe('Automations pillar on a writable package — the control (objectui#11546)', () => {
168+
it('the same click opens the inspector with editable inputs', async () => {
169+
renderPillar(false);
170+
await openFlow();
171+
172+
browserClick(startNodeCard());
173+
174+
const rail = screen.getByRole('complementary');
175+
const label = await within(rail).findByLabelText('Label', undefined, { timeout: 8000 });
176+
expect(startNodeCard()).toHaveAttribute('aria-pressed', 'true');
177+
expect(label).toBeEnabled();
178+
// The read-only case's "no enabled control" reading can fail: here it finds some.
179+
expect(enabledRailControls(rail).length).toBeGreaterThan(0);
180+
});
181+
});

0 commit comments

Comments
 (0)