Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .changeset/action-forward-whitelist-parity-4050-4192.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
'@object-ui/components': patch
---

An action rendered in the overflow menu, as an icon or inside a group now reaches the runner carrying the same authored keys as the same action rendered inline — `action:menu`, `action:icon` and `action:group` forward `label` and `description`, and the two group/icon surfaces also forward `resultDialog`.

Every action renderer hands the `ActionRunner` an explicit key WHITELIST rather than the action itself. That is deliberate — a key no renderer honours must not look wired — but the whitelists had drifted, and which renderer a given action gets is decided by `action:bar`'s `maxVisible` split (3 on desktop, 1 on mobile) and by `systemActions`, which are always in the overflow menu. So the same declared action behaved differently depending on the viewport.

`label` and `description` are what the console's param-collection handler titles its dialog from (`title: action?.label || action?.title`, `description: actionDescription(…, action?.description)`). Dropped, an action with declared `params` opened a dialog titled "Action parameters" while the SAME declaration rendered inline named itself "Create Environment". `resultDialog` is the one-shot reveal spec (a fresh 2FA code, a newly minted OAuth secret): dropped, the runner falls back to the success toast and the value the user was meant to copy is gone — the objectui#3646 defect, still live on two of the four declared surfaces.

`undoable` and `recordIdField` are deliberately NOT added. Both are read only under a `rowRecord` guard, and `rowRecord` is `params._rowRecord`, written exclusively by the spread-based hosts (`DeclaredActionsBar`, `RelatedRecordActionsBridge`, `ObjectGrid`, `page:header`), none of which dispatch through these renderers. They are unreachable on this path rather than dropped — `action:button` forwards them here inertly — so forwarding them would have added a second inert copy instead of restoring an affordance.

A new repo gate, `pnpm check:action-forward-parity`, now derives each surface's owed key set (`authorable ∩ runtime-read − retired`) from the spec's own schemas and the consumers' ASTs and fails when a renderer drops one, so the seventh instance of this class fails on the pull request that introduces it rather than shipping green.
14 changes: 14 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,20 @@ jobs:
if: steps.relevant.outputs.should_run == 'true'
run: pnpm check:spec-symbols

# Each action renderer hands the runner an explicit key WHITELIST, so a new
# spec action key is silently dropped until five separate lists are edited —
# and nothing fails while they are not: the key parses, publishes and reads
# as honoured. Six instances were found by hand, one at a time
# (objectstack#6837 `bodyExtra`, #6938 `bodyShape`, objectui#3646
# `resultDialog`, objectui#4192 `label`/`description`), which is why
# objectui#4050's ruling asks for a gate rather than a seventh review. Same
# placement rationale as the step above: it reads `@objectstack/spec`'s own
# zod shapes and parses the renderers with `typescript`, so it needs the
# install but nothing built.
- name: Verify action renderers forward every key the runtime reads
if: steps.relevant.outputs.should_run == 'true'
run: pnpm check:action-forward-parity

# A key a component asks `t()` for must exist in the `en` pack.
# `all-locales-key-parity.test.ts` compares packs to EACH OTHER, so ten
# packs identically missing a key is full parity and full parity is green
Expand Down
2 changes: 1 addition & 1 deletion content/docs/guide/ci-cd-pipeline.md
Original file line number Diff line number Diff line change
Expand Up @@ -169,7 +169,7 @@ it green — which is how two of `type-check`'s gates came to be missing from th
| Job key | Appears as | What it runs | When |
|---|---|---|---|
| `changeset-check` | Changeset Fixed Group Check | `scripts/check-changeset-fixed.mjs` — every workspace package must be in the changeset `fixed` group or explicitly ignored. It checks group *membership*; it does **not** check whether the PR added a changeset. | Every run |
| `type-check` | Type Check | `scripts/check-type-check-coverage.mjs`, then `pnpm check:spec-symbols`, then `pnpm check:i18n-keys`, then `pnpm check:i18n-drift`, then `pnpm type-check:scripts`, then `pnpm type-check`, then `pnpm type-check:vitest-setup`. The coverage guard runs first because turbo silently skips packages that have no `type-check` script, so a package without one would otherwise read as passing (#2911). The two locale gates sit in the middle because both parse the sources with `typescript`: they need the install and nothing built. `pnpm check:i18n-keys` fails when a `t()` call site asks for a key the `en` pack does not define ([#3530](https://github.com/objectstack-ai/objectui/issues/3530)); `pnpm check:i18n-drift` fails when a change to an `en` string is not accompanied by the nine translation packs ([#3650](https://github.com/objectstack-ai/objectui/issues/3650)), and it is why this job's checkout sets `fetch-depth: 0` — it diffs against the merge base, which a depth-1 clone cannot resolve. `pnpm type-check:scripts` (`tsconfig.scripts.json`) covers `scripts/**/*.ts`, which `pnpm type-check` cannot reach at all — `scripts/` has no package.json, so turbo never walks it, and the coverage guard decides coverage per *package*. Until [#3494](https://github.com/objectstack-ai/objectui/issues/3494) that left the pin tests in `scripts/__tests__/` — including the one pinning this very page — compiled by nothing. `pnpm type-check:vitest-setup` (`tsconfig.vitest-setup.json`) closes the same gap for the four repo-root `vitest.setup.*` files, uncovered until [#3515](https://github.com/objectstack-ai/objectui/issues/3515); it runs *last*, after `pnpm type-check`, because `vitest.setup.dom.tsx` side-effect-imports four `@object-ui/*` packages and resolves them through the declarations that turbo's `^build` produces. | Every run; on a PR the steps short-circuit when only ignored paths changed |
| `type-check` | Type Check | `scripts/check-type-check-coverage.mjs`, then `pnpm check:spec-symbols`, then `pnpm check:action-forward-parity`, then `pnpm check:i18n-keys`, then `pnpm check:i18n-drift`, then `pnpm type-check:scripts`, then `pnpm type-check`, then `pnpm type-check:vitest-setup`. The coverage guard runs first because turbo silently skips packages that have no `type-check` script, so a package without one would otherwise read as passing (#2911). `pnpm check:action-forward-parity` fails when an action renderer's forward whitelist drops a key the action runtime reads — the class that shipped six times one key at a time, each time green, because the key parses and publishes while the payload is dropped one hop before the runner ([#4050](https://github.com/objectstack-ai/objectui/issues/4050)). The two locale gates sit in the middle because both parse the sources with `typescript`: they need the install and nothing built. `pnpm check:i18n-keys` fails when a `t()` call site asks for a key the `en` pack does not define ([#3530](https://github.com/objectstack-ai/objectui/issues/3530)); `pnpm check:i18n-drift` fails when a change to an `en` string is not accompanied by the nine translation packs ([#3650](https://github.com/objectstack-ai/objectui/issues/3650)), and it is why this job's checkout sets `fetch-depth: 0` — it diffs against the merge base, which a depth-1 clone cannot resolve. `pnpm type-check:scripts` (`tsconfig.scripts.json`) covers `scripts/**/*.ts`, which `pnpm type-check` cannot reach at all — `scripts/` has no package.json, so turbo never walks it, and the coverage guard decides coverage per *package*. Until [#3494](https://github.com/objectstack-ai/objectui/issues/3494) that left the pin tests in `scripts/__tests__/` — including the one pinning this very page — compiled by nothing. `pnpm type-check:vitest-setup` (`tsconfig.vitest-setup.json`) closes the same gap for the four repo-root `vitest.setup.*` files, uncovered until [#3515](https://github.com/objectstack-ai/objectui/issues/3515); it runs *last*, after `pnpm type-check`, because `vitest.setup.dom.tsx` side-effect-imports four `@object-ui/*` packages and resolves them through the declarations that turbo's `^build` produces. | Every run; on a PR the steps short-circuit when only ignored paths changed |
| `test` | Test (shard N/4) | `pnpm test --shard=N/4` across a 4-runner matrix with `fail-fast: false`, so every shard reports its own failures. No coverage instrumentation — v8 adds 40–100% overhead. | Pull requests and merge-queue builds (everything but `push`); steps short-circuit on a PR that changed only ignored paths |
| `test-coverage` | Test (coverage) | One unsharded `pnpm test:coverage`, uploaded to Codecov. Nothing blocks on it, which is why it is not sharded. | **Push only** |
| `e2e` | Build & E2E | Builds the console with `vite build` (`VITE_BASE_PATH=/console/`), verifies the artifact, then `pnpm test:e2e --project=chromium`. Uploads the Playwright report on failure. | Every run; on a PR the steps short-circuit when only ignored paths changed |
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
"type-check:scripts": "tsc -p tsconfig.scripts.json",
"type-check:vitest-setup": "tsc -p tsconfig.vitest-setup.json",
"check:spec-symbols": "node scripts/check-spec-symbol-derivation.mjs",
"check:action-forward-parity": "node scripts/check-action-forward-parity.mjs",
"check:control-bytes": "node scripts/check-control-bytes.mjs",
"check:i18n-keys": "node scripts/check-i18n-call-site-keys.mjs",
"check:i18n-drift": "node scripts/check-i18n-en-drift.mjs",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
/**
* ObjectUI
* Copyright (c) 2024-present ObjectStack Inc.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/

/**
* objectui#4192 — the same declared action, rendered inline or in the overflow
* menu, must reach the runner carrying the same authored keys.
*
* Which renderer an action gets is decided by `action:bar`'s `maxVisible` split
* (3 desktop, 1 mobile) and by `systemActions`, which are ALWAYS in the menu. So
* a whitelist that has drifted between the two renderers makes the same
* declaration behave differently by VIEWPORT WIDTH — the class of divergence
* #4162 and objectui#4075 each hit on a different key set.
*
* `label` and `description` are the measured instance. The console's param
* collection handler titles its dialog from exactly those two —
* `title: action?.label || action?.title` and
* `description: actionDescription(…, action?.description)`
* (useConsoleActionRuntime.tsx:205-207) — so an overflow action opened an
* untitled dialog while the SAME declaration, rendered inline, named itself.
*
* ## Why this file exists alongside `scripts/check-action-forward-parity.mjs`
*
* The gate (objectui#4050) reads the whitelists statically: it proves the key
* appears in the `execute({…})` literal. It cannot prove the value SURVIVES the
* hop — that the runner receives it and that the handler which titles the dialog
* sees it. These pins drive the real renderers through the real runner and
* assert on what the handler is actually handed, so the two halves fail for
* different reasons: delete a key from the whitelist and both go red; break the
* runner's param-collection dispatch and only this file does.
*
* ## Reachability, deliberately NOT pinned here
*
* `undoable` and `recordIdField` are absent from these payloads on purpose, and
* asserting they arrive would pin a fiction. Both are read only under a
* `rowRecord` guard, and `rowRecord` is `params._rowRecord` — written by the
* spread-based hosts (`DeclaredActionsBar`, `RelatedRecordActionsBridge`,
* `ObjectGrid`, `page:header`), never by these renderers. `action:button`
* forwards them INERTLY on this path; the menu omitting them costs nothing.
* That verdict is carried, with its evidence, in the gate's JUSTIFIED table.
*/

import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, waitFor } from '@testing-library/react';
import React from 'react';
import { ComponentRegistry } from '@object-ui/core';
import { ActionProvider } from '@object-ui/react';
// Module-scope side-effect imports — `action:bar` resolves its members and the
// overflow menu through the ComponentRegistry at render time, and the light
// `dom` project does not load the `@object-ui/components` graph. Module scope,
// not a `beforeAll`, per AGENTS.md §测试纪律.
import '../action-bar';
import '../action-button';
import '../action-menu';

/** Three ordinary actions — enough to fill the desktop `maxVisible: 3`. */
const FILLERS = [
{ name: 'a1', label: 'a1', type: 'api', locations: ['list_toolbar'] },
{ name: 'a2', label: 'a2', type: 'api', locations: ['list_toolbar'] },
{ name: 'a3', label: 'a3', type: 'api', locations: ['list_toolbar'] },
];

/**
* The card's action. `params` is an ARRAY — a parameter DEFINITION list — so the
* runner opens param collection before executing, which is the path whose dialog
* was going untitled. `autoTrigger` runs `handleExecute`, the identical function
* a click on the menu item calls, without opening the Radix dropdown (whose
* pointerdown-driven portal is flaky to synthesize in happy-dom — see
* `action-group-dropdown-visible.test.tsx`).
*/
const CREATE = {
name: 'create_environment',
label: 'Create Environment',
description: 'Provision a new environment for this project',
type: 'api',
locations: ['list_toolbar'],
params: [{ name: 'title', type: 'text', label: 'Title' }],
autoTrigger: true,
};

let api: ReturnType<typeof vi.fn>;
let onParamCollection: ReturnType<typeof vi.fn>;

beforeEach(() => {
api = vi.fn(async () => ({ success: true }));
// Cancel collection: this pin is about what the handler is HANDED, and
// cancelling keeps the assertion off everything downstream of the dialog.
onParamCollection = vi.fn(async () => null);
});

function Bar({ actions }: { actions: any[] }) {
const C = ComponentRegistry.get('action:bar');
if (!C) throw new Error('action:bar is not registered');
// eslint-disable-next-line react-hooks/static-components -- ComponentRegistry.get returns a registered renderer (stable reference), not a component created during render
return <C schema={{ type: 'action:bar', location: 'list_toolbar', actions }} />;
}

const renderBar = (actions: any[]) =>
render(
<ActionProvider handlers={{ api }} onParamCollection={onParamCollection}>
<Bar actions={actions} />
</ActionProvider>,
);

/** The action object the param-collection handler was handed. */
const collectedAction = () => onParamCollection.mock.calls[0]?.[1] as any;

describe('action:menu forwards the keys that title a param dialog (#4192)', () => {
it('the overflow path hands the dialog the action`s own label and description', async () => {
// Four actions, `maxVisible: 3` → the create action is 4th, so `action:bar`
// routes it to `action:menu`. Before the fix the handler received
// `label: undefined, description: undefined` here and the console titled
// the dialog "Action parameters".
renderBar([...FILLERS, CREATE]);

await waitFor(() => expect(onParamCollection).toHaveBeenCalledTimes(1));
expect(collectedAction().label).toBe('Create Environment');
expect(collectedAction().description).toBe('Provision a new environment for this project');
});

it('is indistinguishable from the inline path — the split must not decide the title', async () => {
// The SAME declaration with nothing to overflow past: `action:bar` keeps it
// inline and it renders through `action:button`. Both renderers must hand
// the handler the same two keys, or the dialog's title becomes a function of
// viewport width.
renderBar([CREATE]);

await waitFor(() => expect(onParamCollection).toHaveBeenCalledTimes(1));
const inline = collectedAction();
expect(inline.label).toBe('Create Environment');
expect(inline.description).toBe('Provision a new environment for this project');
});

it('still routes an array `params` as the collection definition, not as a payload', async () => {
// The menu passes `params: action.params` where the button routes an array
// to `actionParams`; the runner accepts either (ActionRunner.ts:816), which
// is why #4192 was a wrong TITLE and not a missing dialog. Pinned so the
// divergence stays latent rather than becoming the next defect.
renderBar([...FILLERS, CREATE]);

await waitFor(() => expect(onParamCollection).toHaveBeenCalledTimes(1));
const paramDefs = onParamCollection.mock.calls[0][0] as any[];
expect(paramDefs).toHaveLength(1);
expect(paramDefs[0].name).toBe('title');
});

it('carries the label through to the runner itself, not only to the dialog', async () => {
// No `params`, so nothing to collect and the action runs: the `api` handler
// receives the ActionDef the menu composed. Asserting here as well as at the
// dialog keeps the pin honest if param collection is ever restructured.
const { name, type, locations, label, description } = CREATE;
renderBar([...FILLERS, { name, type, locations, label, description, autoTrigger: true }]);

await waitFor(() => expect(api).toHaveBeenCalledTimes(1));
const def = api.mock.calls[0][0] as any;
expect(def.name).toBe('create_environment');
expect(def.label).toBe('Create Environment');
expect(def.description).toBe('Provision a new environment for this project');
});
});
9 changes: 9 additions & 0 deletions packages/components/src/renderers/action/action-group.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -239,6 +239,11 @@ const ActionGroupRenderer = forwardRef<HTMLDivElement, { schema: ActionGroupSche
await execute({
type: action.type,
name: action.name,
// See action-button.tsx — the param-collection dialog reads its title
// and description off these (objectui#4192, measured on `action:menu`
// and found here by `check:action-forward-parity`).
label: action.label,
description: (action as any).description,
target: action.target,
openIn: (action as any).openIn,
endpoint: action.endpoint,
Expand All @@ -255,6 +260,10 @@ const ActionGroupRenderer = forwardRef<HTMLDivElement, { schema: ActionGroupSche
// Placement declaration — see action-button.tsx (#2210).
locations: action.locations,
toast: action.toast,
// See action-button.tsx — the one-shot reveal spec (2FA setup, fresh
// OAuth secret). Without it the runner falls back to the success
// toast and the value the user was meant to copy is gone.
resultDialog: (action as any).resultDialog,
});
},
[execute],
Expand Down
9 changes: 9 additions & 0 deletions packages/components/src/renderers/action/action-icon.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,11 @@ const ActionIconRenderer = forwardRef<HTMLButtonElement, ActionIconProps>(
await execute({
type: schema.type,
name: schema.name,
// See action-button.tsx — the param-collection dialog reads its title
// and description off these (objectui#4192, measured on `action:menu`
// and found here by `check:action-forward-parity`).
label: schema.label,
description: (schema as any).description,
target: schema.target,
openIn: (schema as any).openIn,
endpoint: schema.endpoint,
Expand All @@ -87,6 +92,10 @@ const ActionIconRenderer = forwardRef<HTMLButtonElement, ActionIconProps>(
// Placement declaration — see action-button.tsx (#2210).
locations: (schema as any).locations,
toast: schema.toast,
// See action-button.tsx — the one-shot reveal spec (2FA setup, fresh
// OAuth secret). Without it the runner falls back to the success
// toast and the value the user was meant to copy is gone.
resultDialog: (schema as any).resultDialog,
...localContext,
});
} finally {
Expand Down
Loading
Loading