Skip to content

Commit 74fb2f7

Browse files
huangyiireneclaude
andauthored
feat(platform-objects): declare the sys_user set_user_manager row action (#19249) (#19316)
Part of #19249 > ⛔ **`Part of`, ⛔ not `Fixes` — changed by the dispatching seat after review, ⛔ not by the author.** > The card's scope item 1 names two behaviours: re-point a manager, and **`managerId: null` clears**. This PR delivers the first. The second is not declarable until one reading nobody in this container can take — whether objectui's param dialog submits an explicit `null` for an untouched optional lookup — because the endpoint refuses an ABSENT `managerId` and treats only an explicit `null` as a clear (`packages/plugins/plugin-auth/src/admin-set-user-manager.test.ts`: `:162` pins the null-clear, `:175` pins 「an ABSENT managerId is refused, never read as a clear」). ⇒ merging this must **not** close the card. Clause-②: no ## What this declares `sys_user.manager_id` drives the approvals `{ type: 'manager' }` rung and the ADR-0057 `own_and_reports` read scope, and `POST /api/v1/auth/admin/set-user-manager` (#16678 Phase 3) has been its only product write surface since it landed — with nothing in the Console reaching it. This declares that affordance and nothing else: one `set_user_manager` row action on `sys_user`, offered on `list_item` and `record_header`, collecting the manager through an inline `sys_user` lookup and POSTing `{ userId, managerId }` to the admin endpoint. Origin ruling (objectstack#16678 Phase 2, decision batch #127 item 1, maintainer 2026-09-13), verbatim: > 同意 经理 = 管理员在用户上显式设置的 manager_id;部门负责人 = 单元上的 manager_user_id,两者独立。 So `sys_business_unit.manager_user_id` (Business Unit Head) is untouched: not read, not written, not derived from or for. The read side is untouched too — `manager_id` keeps `readonly: true` and renders in the existing `group: 'Organization'` exactly as before (re-read on the branch base; ADR-0092 D4). ⛔ It does not write `manager_id` through the generic data API. `sys_user` is `managedBy: 'better-auth'` and the ADR-0092 D2 managed-update whitelist is `{name, image, locale}`, so that write is refused by the identity write guard — correctly — and the failure would read as a Console bug. The endpoint reaches the column by system context instead, which is why no Tier-1 list moves. ⛔ It declares no second copy of the server's refusals. Self-assignment, cycle, depth, cross-organization and directory-owned identity are all enforced at the write, in one derivation (`applyUserManagerLink`, which the bulk importer already routes onto rather than re-deriving), and surface from there. ## Three readings that changed the shape ### 1. The `visible` predicate — the card's count holds; copying the predicate whole would not The card calls `record.source != "idp_provisioned"` "the shape the three existing self-service identity actions already use". Measured on the branch base: there are exactly three (`change_my_password`, `change_my_email`, `delete_my_account`), and all three spell that term byte-identically. But all three also AND it with `has(record.id) && record.id == ctx.user.id` — they are self-service actions, offered to the row owner. This is an **admin** action on someone else's row, so only the directory-sync term is carried: ``` visible: 'has(record.source) && record.source != "idp_provisioned"' ``` Copying the predicate whole would have hidden the button from every admin — silently and fail-closed, the #8990 shape. A per-site verdict pins the counter-direction (offered to an admin on someone else's `env_native` row) beside the directory-owned verdict, because a guard that is accidentally always-false is user-visibly identical to the bug. ### 2. No `requiresFeature: 'admin'` — the one key where this departs from its precedent, deliberately `unlock_user` and `set_user_password` carry it, and the block header states why: those actions hit endpoints "that are only wired when `auth.plugins.admin` is enabled", so the gate keeps the UI from rendering buttons that 404. **This route is not one of them.** It is an ObjectStack mount registered unconditionally beside `unlock-user` in `auth-plugin.ts`, authorized by the ADR-0068 platform-admin gate (`judgePlatformAdmin`), never by the better-auth admin plugin. Gating it on `features.admin == true` would hide a working affordance on every host that never opted into that plugin — precisely the population #16678 measured as having no write surface for this column at all. There is a second-order consequence worth stating, because it also decides the file surface: `PUBLIC_AUTH_FEATURES.admin.gatedInputs` in `packages/spec` enumerates every action gated on that flag, and `feature-gate-guard.test.ts` reds in its **reverse** direction when an action carries a `features.*` term that is not booked there. Declaring the gate would therefore have required a `packages/spec` edit; not declaring it requires none. The absence is pinned together with that consequence, so a later flip cannot happen without reading it. ### 3. The spec fork did not fire — and the one half of the suggested route that would have fired it Everything here uses existing action keys: `type`, `target`, `recordIdParam`, `visible`, `description`, `successMessage`, `refreshAfter`, and a `params[]` entry of `type: 'lookup'` with `reference`. No new or widened `packages/spec` key, no spec file touched, so `Clause-②: no` holds. The half that is **not** declarable is "a user lookup filtered to the same organization". `ActionParamSchema` is strict and declares no filter key at all; the only structured picker filter in the schema is `FieldSchema.lookupFilters`, whose entries are literal `{ field, operator, value }` triples with no context token — and `sys_user` carries no `organization_id` column to filter on, being a global identity table (`sys_member` rows are the only tenancy fact either identity has, which is exactly why the endpoint's cross-organization screen reads `sys_member`). So the org-scoping half has no existing-key spelling, and what does exist is the server's named `cross_organization` refusal. A client-side approximation of it would have been the second copy this card forbids, so the picker is left unscoped and the refusal surfaces. ## Verification Gate families derived from the actual diff with `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands`, every command run with its exit code captured before any pipe, reconciled with `--ran`: ``` dispatch-gates --ran: 58 derived famil(ies) accounted for — 58 run, 0 NOT-MEASURED (a DERIVED zero — all 58 recorded an exit code and none of them is 3). ``` | check | result | |---|---| | 58 derived gate families | all exit 0 | | `pnpm --filter @objectstack/platform-objects test` | 41 files / 584 tests passed | | `pnpm --filter @objectstack/platform-objects typecheck` | OK (incl. `check:test-typecheck`) | | `pnpm build` | 73/73 tasks | | `pnpm check:i18n` | OK — 9 packages, all bundles in sync | | `pnpm check:i18n-coverage` | OK — 13 configs, 621 baselined untranslated strings, **none new** | | `pnpm lint` (`eslint . --no-inline-config`, whole repo) | exit 0 at `b0131a89f` | `pnpm check:dual-build-cjs-loads` first answered **exit 3 — PREREQUISITE NOT MET** (no `dist/` for 12 packages). That is not a pass, so the prerequisite was cleared with a full `pnpm build` and the gate re-run: exit 0. Control-character self-scan over all seven changed files: no match. The i18n bundles were regenerated with `node scripts/check-i18n-bundles.mjs --write`, and the three translated locales were then **hand-translated** rather than left as the extractor's English fill — the `#7309` trap: an English value in a non-English bundle is perfectly "in sync" to `check:i18n` and invisible to every gate. The generated source-hash tables drop their entries for a re-translated leaf by themselves, which is why they carry no diff here. ## Acceptance notes - **Clearing the link has no affordance in this action, and that is a declared narrowing rather than an oversight.** The endpoint's clear path requires `managerId` to be **present and `null`** — "managerId is required — send null to clear the link, never omit the key" — and refuses both an absent key and an empty string. Whether the Console's param dialog submits an explicit `null` for an untouched optional lookup is objectui behaviour, and objectui is not checked out in this container, so it could not be measured here. Rather than half-declare it, the param is `required: true`: the dialog collects a value before anything is POSTed, so no submit path can produce that 400 about a key the user never saw. Re-pointing a manager works; unsetting one still needs either a measured `defaultValue: null` path or a companion action carrying `bodyExtra: { managerId: null }` — one existing-key line either way, on a measurement this container cannot take. Noted, not filed; successor: the next author of a `sys_user` action, whom this note and the pin in `sys-user-set-manager-action.test.ts` both reach. - **The file surface ran wider than the dispatch's `packages/platform-objects/src/identity/` line, mechanically and in one direction only.** A new action label, description, success message and param label are authorable i18n keys, so `pnpm check:i18n` reds until `src/apps/translations/*.objects.generated.ts` is regenerated. Four bundle files outside `identity/`, all generated-then-translated, no hand-written structure. Flagged rather than silently widened. - Noted, not filed: `unlock_user` carries `requiresFeature: 'admin'` while its own route is mounted unconditionally on the raw app, so on a host without the better-auth admin plugin the Unlock Account button is hidden although the endpoint answers. Same class as the reading in §2 above, on an action this PR does not touch. Successor: whoever next revisits the `#2874` feature-gate roster. - Noted, not filed: `sys_user.manager_id`'s own field `help` string is untranslated English in all three translated bundles (pre-existing, inside the 621 baselined strings this PR leaves flat). --- _Generated by [Claude Code](https://claude.ai/code)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 81e12e1 commit 74fb2f7

7 files changed

Lines changed: 327 additions & 0 deletions

File tree

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
---
2+
'@objectstack/platform-objects': minor
3+
---
4+
5+
feat(platform-objects): declare the `set_user_manager` row action on `sys_user` (#19249)
6+
7+
`sys_user.manager_id` drives the approvals `{ type: 'manager' }` rung and the ADR-0057 `own_and_reports` read scope, and `POST /api/v1/auth/admin/set-user-manager` (#16678 Phase 3) has been its only product write surface since it landed — with nothing in the Console reaching it. This declares that affordance: a `set_user_manager` row action on `sys_user`, offered from the Users list row menu and the record-detail header, collecting the new manager through an inline `sys_user` lookup and POSTing `{ userId, managerId }` to the admin endpoint.
8+
9+
Three properties of the declaration are decisions rather than detail:
10+
11+
- **It posts the admin endpoint, never the generic data API.** `sys_user` is `managedBy: 'better-auth'` and the ADR-0092 D2 managed-update whitelist is `{name, image, locale}`, so a picker writing `manager_id` through `/api/v1/data` would be refused by the identity write guard — correctly — and would read as a Console bug. The field keeps `readonly: true`; the endpoint reaches the column by system context.
12+
- **Its `visible` predicate carries the directory-sync term and not the self-service one.** A directory-owned identity (`source: 'idp_provisioned'`) is refused by the endpoint, so the button is hidden for one — the same term the three self-service identity actions on this object already spell. Their `record.id == ctx.user.id` half is deliberately not carried over: this is an admin action on someone else's row.
13+
- **No second copy of the server's refusals.** Self-assignment, cycle, depth, cross-organization and directory-owned identity are enforced at the write, in one derivation, and surface from there. Nothing is re-derived client-side.
14+
15+
Additive: no existing action, field or predicate changed. The `manager_id` field and its read-only rendering are untouched, and `sys_business_unit.manager_user_id` (Business Unit Head) is a separate, independent relation that this does not read or write.

‎packages/platform-objects/src/apps/translations/en.objects.generated.ts‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -218,6 +218,17 @@ export const enObjects: NonNullable<TranslationData['objects']> = {
218218
confirmText: "Start an impersonation session for this user? Use only for legitimate support cases — actions will be logged.",
219219
successMessage: "Now impersonating user"
220220
},
221+
set_user_manager: {
222+
label: "Set Manager",
223+
description: "Set this user's manager. The reporting chain drives approval routing and the own_and_reports record scope.",
224+
successMessage: "Manager updated",
225+
params: {
226+
managerId: {
227+
label: "Manager",
228+
helpText: "The user this person reports to. The server refuses a manager outside their organization, a self-assignment, and a link that would close or over-deepen the reporting chain."
229+
}
230+
}
231+
},
221232
update_my_profile: {
222233
label: "Update Profile",
223234
successMessage: "Profile updated"

‎packages/platform-objects/src/apps/translations/es-ES.objects.generated.ts‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -218,6 +218,17 @@ export const esESObjects: NonNullable<TranslationData['objects']> = {
218218
confirmText: "¿Iniciar una sesión de suplantación para este usuario? Úsela solo para casos legítimos de soporte; las acciones se registrarán.",
219219
successMessage: "Ahora está suplantando al usuario"
220220
},
221+
set_user_manager: {
222+
label: "Establecer gerente",
223+
description: "Establece el gerente de este usuario. La cadena de reporte determina el enrutamiento de aprobaciones y el alcance de registros own_and_reports.",
224+
successMessage: "Gerente actualizado",
225+
params: {
226+
managerId: {
227+
label: "Gerente",
228+
helpText: "La persona a la que reporta este usuario. El servidor rechaza un gerente de otra organización, la autoasignación y un vínculo que cerraría un ciclo o superaría la profundidad máxima de la cadena de reporte."
229+
}
230+
}
231+
},
221232
update_my_profile: {
222233
label: "Actualizar perfil",
223234
successMessage: "Perfil actualizado"

‎packages/platform-objects/src/apps/translations/ja-JP.objects.generated.ts‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -218,6 +218,17 @@ export const jaJPObjects: NonNullable<TranslationData['objects']> = {
218218
confirmText: "このユーザーとして代理ログインを開始しますか?正当なサポート対応時のみ使用してください。操作は監査ログに記録されます。",
219219
successMessage: "代理ログイン中"
220220
},
221+
set_user_manager: {
222+
label: "マネージャーを設定",
223+
description: "このユーザーのマネージャーを設定します。レポートラインは承認ルーティングと own_and_reports のレコード範囲を決定します。",
224+
successMessage: "マネージャーを更新しました",
225+
params: {
226+
managerId: {
227+
label: "マネージャー",
228+
helpText: "このユーザーの直属の上長。組織が異なるマネージャー、自分自身の指定、レポートラインが循環する、または深さの上限を超える指定はサーバー側で拒否されます。"
229+
}
230+
}
231+
},
221232
update_my_profile: {
222233
label: "プロフィール更新",
223234
successMessage: "プロフィールを更新しました"

‎packages/platform-objects/src/apps/translations/zh-CN.objects.generated.ts‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -218,6 +218,17 @@ export const zhCNObjects: NonNullable<TranslationData['objects']> = {
218218
confirmText: "要为该用户启动模拟会话吗?仅限合法支持场景使用——所有操作都会被记录。",
219219
successMessage: "已开始模拟该用户"
220220
},
221+
set_user_manager: {
222+
label: "设置经理",
223+
description: "设置该用户的经理。汇报链决定审批路由与 own_and_reports 记录范围。",
224+
successMessage: "已更新经理",
225+
params: {
226+
managerId: {
227+
label: "经理",
228+
helpText: "该用户的直接汇报对象。服务端会拒绝不在同一组织的经理、把用户指派给自己,以及会形成环路或超出汇报链深度上限的关系。"
229+
}
230+
}
231+
},
221232
update_my_profile: {
222233
label: "更新资料",
223234
successMessage: "已更新资料"
Lines changed: 182 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,182 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* #19249 — the `set_user_manager` row action on `sys_user`, pinned against the
5+
* three things about it that are decisions rather than code.
6+
*
7+
* `sys_user.manager_id` drives the approvals `manager` rung and the ADR-0057
8+
* `own_and_reports` read scope, and `POST /api/v1/auth/admin/set-user-manager`
9+
* (#16678 Phase 3) is its only product write surface. This action is the
10+
* Console affordance that reaches it, and each assertion below exists because
11+
* the OBVIOUS edit at that spot is the wrong one:
12+
*
13+
* 1. **The write goes to the admin endpoint, never the generic data API.**
14+
* `sys_user` is `managedBy: 'better-auth'` and the ADR-0092 D2
15+
* managed-update whitelist is `{name, image, locale}`, so a picker that
16+
* PATCHed `/api/v1/data/sys_user/:id` would be refused by the identity
17+
* write guard — correctly — and would read as a Console bug.
18+
* 2. **The `visible` predicate carries the directory-sync term and NOT the
19+
* self-service ownership term.** The three self-service identity actions
20+
* on this object spell `record.source != "idp_provisioned"` as one half of
21+
* `record.id == ctx.user.id && …`. Copying that predicate whole — the
22+
* natural thing to do, and what a reader of the card would do — would hide
23+
* an ADMIN action on someone else's row from every admin. The failure is
24+
* silent and fail-closed (#8990): the button simply is not offered.
25+
* 3. **No second copy of the server's refusals.** Self-assignment, cycle,
26+
* depth, cross-organization and directory-owned identity are all enforced
27+
* at the write, in one derivation (`applyUserManagerLink`), which the bulk
28+
* importer already routes onto rather than re-deriving. A client-side
29+
* duplicate is the drift, not the safety net — so this file pins that the
30+
* declaration reads exactly one record column and declares no predicate
31+
* for any of the other four refusals.
32+
*
33+
* The sweep in `action-predicate-sparse-face.test.ts` already covers this
34+
* action's predicate for the sparse-face guard; that is deliberately not
35+
* restated here.
36+
*/
37+
38+
import { describe, expect, it } from 'vitest';
39+
import { celEngine } from '@objectstack/formula';
40+
import { SysUser } from './sys-user.object.js';
41+
42+
type AnyParam = Record<string, unknown>;
43+
type AnyAction = Record<string, unknown> & { name?: string; params?: AnyParam[] };
44+
45+
const actionsOf = (): AnyAction[] => ((SysUser.actions ?? []) as unknown as AnyAction[]);
46+
47+
const ACTION = (() => {
48+
const found = actionsOf().find((a) => a.name === 'set_user_manager');
49+
if (!found) throw new Error('sys_user declares no set_user_manager action');
50+
return found;
51+
})();
52+
53+
/**
54+
* `defineObject` normalizes a CEL shorthand into a `{dialect, source}`
55+
* envelope at parse time, so the stored value is never the string the file
56+
* spells. Read through this or the assertions run against `undefined`.
57+
*/
58+
function sourceOf(raw: unknown): string | undefined {
59+
if (typeof raw === 'string') return raw;
60+
if (raw && typeof raw === 'object' && typeof (raw as { source?: unknown }).source === 'string') {
61+
return (raw as { source: string }).source;
62+
}
63+
return undefined;
64+
}
65+
66+
/** Evaluate through the canonical engine; a fault is reported, never thrown. */
67+
function evaluate(source: string, record: Record<string, unknown>): boolean | string {
68+
const r = celEngine.evaluate(
69+
{ dialect: 'cel', source },
70+
{ record, user: { id: 'admin_1' }, extra: { features: {} } },
71+
);
72+
if (!r.ok) return `FAULT ${r.error.message.split('\n')[0].trim()}`;
73+
return typeof r.value === 'boolean' ? r.value : `NON-BOOLEAN ${JSON.stringify(r.value)}`;
74+
}
75+
76+
describe('#19249 — sys_user.set_user_manager posts the admin endpoint', () => {
77+
it('targets the ruled route with the ruled record-id key', () => {
78+
expect(ACTION.type).toBe('api');
79+
expect(ACTION.target).toBe('/api/v1/auth/admin/set-user-manager');
80+
// The endpoint reads `userId` (and `user_id`) off the body; the row id is
81+
// injected under that key rather than collected from the user.
82+
expect(ACTION.recordIdParam).toBe('userId');
83+
// POST is the default and the only method this endpoint is mounted for;
84+
// declaring PATCH/PUT here would 404 at the click.
85+
expect(ACTION.method === undefined || ACTION.method === 'POST').toBe(true);
86+
});
87+
88+
it('⛔ does NOT write manager_id through the generic data API', () => {
89+
// The whole prohibition on this card, expressed as a predicate over the
90+
// declaration: no data-plane target, and no declarative row write (which
91+
// runs on the data plane AS THE CALLER and would hit the same guard).
92+
expect(String(ACTION.target)).not.toContain('/data/');
93+
expect(ACTION.operation).toBeUndefined();
94+
expect(ACTION.patch).toBeUndefined();
95+
});
96+
97+
it('surfaces on the row menu AND the record-detail header', () => {
98+
expect(ACTION.locations).toContain('list_item');
99+
expect(ACTION.locations).toContain('record_header');
100+
});
101+
102+
it('collects the manager as an inline sys_user lookup under the body key the endpoint reads', () => {
103+
const params = (ACTION.params ?? []) as AnyParam[];
104+
expect(params).toHaveLength(1);
105+
const [p] = params;
106+
expect(p.name).toBe('managerId');
107+
expect(p.type).toBe('lookup');
108+
expect(p.reference).toBe('sys_user');
109+
// INLINE, not field-backed: `{ field: 'manager_id' }` would inherit the
110+
// referenced field's metadata, and that field is `readonly: true`
111+
// (ADR-0092 D4 keeps it non-editable in the standard edit form).
112+
expect(p.field).toBeUndefined();
113+
// Required, because the endpoint refuses an ABSENT or empty key by design
114+
// ("managerId is required — send null to clear the link, never omit the
115+
// key"). A dialog that could submit nothing would turn an ordinary click
116+
// into a 400 about a key the user never saw.
117+
expect(p.required).toBe(true);
118+
});
119+
120+
it('⛔ declares no second copy of the server-side refusals', () => {
121+
// Every refusal is enforced at the write and surfaces from there. What
122+
// would go wrong quietly is a client-side re-derivation drifting from the
123+
// endpoint's; so the predicate is pinned to the ONE column it reads, and
124+
// the declaration to having no other gate at all.
125+
const visible = sourceOf(ACTION.visible);
126+
expect(visible).toBeDefined();
127+
const columns = new Set(
128+
[...visible!.matchAll(/record\.([a-z_][a-z0-9_]*)/gi)].map((m) => m[1]),
129+
);
130+
expect([...columns]).toEqual(['source']);
131+
expect(ACTION.disabled).toBeUndefined();
132+
// The manager chain, the organization screen and the self-assignment check
133+
// are the endpoint's; none of them is expressible — or declared — here.
134+
const declaration = JSON.stringify(ACTION);
135+
expect(declaration).not.toContain('ctx.user.id');
136+
expect(declaration).not.toContain('manager_user_id');
137+
expect(declaration).not.toContain('sys_business_unit');
138+
});
139+
});
140+
141+
describe('#19249 — the visible predicate: directory-sync term, NOT the self-service one', () => {
142+
const visible = (): string => {
143+
const source = sourceOf(ACTION.visible);
144+
if (!source) throw new Error('set_user_manager has no CEL source');
145+
return source;
146+
};
147+
148+
it('is hidden for a directory-owned identity (the server answers 403 idp_provisioned)', () => {
149+
expect(evaluate(visible(), { source: 'idp_provisioned' })).toBe(false);
150+
});
151+
152+
it('⭐ is OFFERED to an admin on someone else\'s env-native row', () => {
153+
// The counter-direction, and the one that a verbatim copy of the
154+
// self-service predicate would break: this row is not the signed-in user.
155+
expect(evaluate(visible(), { id: 'someone_else', source: 'env_native' })).toBe(true);
156+
});
157+
158+
it('carries the same directory-sync term the self-service actions spell', () => {
159+
// Byte-identical term, so the two never drift into two spellings of one
160+
// rule — while the ownership half they also carry stays out (asserted
161+
// above by the offered-to-an-admin verdict).
162+
expect(visible()).toContain('record.source != "idp_provisioned"');
163+
const selfService = actionsOf().find((a) => a.name === 'change_my_password');
164+
expect(sourceOf(selfService?.visible)).toContain('record.source != "idp_provisioned"');
165+
});
166+
167+
it('⛔ is NOT gated on features.admin, and that is deliberate', () => {
168+
// The `admin` flag hides buttons whose endpoint is "only wired when
169+
// `auth.plugins.admin` is enabled" — the 404-avoidance the block header on
170+
// the admin actions states. This route is not one of those: it is an
171+
// ObjectStack mount registered unconditionally beside `unlock-user` and
172+
// authorized by the ADR-0068 platform-admin gate, so gating it would hide
173+
// a WORKING affordance on every host that never opted into that plugin —
174+
// exactly the population #16678 measured as having no write surface for
175+
// this column at all. If this assertion is ever flipped, the registry
176+
// entry `PUBLIC_AUTH_FEATURES.admin.gatedInputs` must gain
177+
// `sys_user.actions.set_user_manager` in the same change, or
178+
// `feature-gate-guard.test.ts` goes red in its reverse direction.
179+
expect(ACTION.requiresFeature).toBeUndefined();
180+
expect(visible()).not.toContain('features.');
181+
});
182+
});

0 commit comments

Comments
 (0)