Skip to content

Commit cc70b8f

Browse files
os-zhuangclaude
andauthored
test(console,sdui-parser): public block 声明的 objectName 必须真的到达数据层 (objectstack#4472) (#3146)
* test(console,sdui-parser): public block 声明的 objectName 必须真的到达数据层 (objectstack#4472) framework 那边拿 sdui.manifest.json 去 diff spec 的 zod schema,而那道检查在叫 check:react-conformance 的时候,被它自己的文件头当成"确认组件 ACTUALLY implement 了 spec 声明的 props"。它做不到:diff 的两边都是**声明**,其中一边就是本仓库产出的 ——`manifestFromConfigs` 原样抄 `config.inputs`,看不到渲染器读不读。所以两边都声明、 没人读的 prop 在那边就是"一致",objectstack#4413 的四个 record:* 块正是这么带着没人读 的 objectName/recordId 渲染成空白还一路绿灯的。 渲染路径上的证据只能从渲染路径上取,所以它落在这里。 apps/console/src/__tests__/public-block-binding-reach.test.tsx 把每个声明了 objectName 输入的 public block 经 SchemaRenderer 挂载(只给这一个绑定),provider 的 dataSource 是 一个记录所有调用的 Proxy,断言至少有一次调用带上了这个对象名。刻意窄:问的是"这个绑定 接上了吗",不是"每个声明的 input 都被消费了吗"——后者在外部无启发式不可判定。每个没达标 的块都要在台账里写下理由,并且台账被**双向**断言等于实测集合:接上了就强制删条目,断了 就红。红的两个方向都验证过。 首跑:八个里五个到达,三个没到。record:related_list 是合理的——它必须先从 RecordContext 拿到父记录 id 才允许取数,否则会列出整张子表(@objectstack/spec 的 #4413 台账已写明)。 list-view 和 embeddable-form 不是,是同一形状的真缺陷:这两个注册都没有像 object-form / object-kanban / object-calendar 那样把 schema-renderer context 桥接到组件的 dataSource prop 上,而 SchemaRenderer 从不注入它,于是在注册表/SDUI 路径上两者都渲染空壳,同时把 objectName 声明为 **required**。单独开了 objectui#3144 而不是顺手改:给它们接上数据源会 改变所有裸挂载处的渲染结果。 manifestFromConfigs 和 scripts/dump-public-manifest.mjs 现在在自己的文档里写明:它们 emit 的是注册**声明**了什么,不是渲染器读了什么。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01S3cP1eY1novcNhQEDBrSZD * fix(console): 修掉未使用的 React import,并让探针不再自造假信号 三处,都是上一提交暴露出来的: 1. `import React` 在自动 JSX runtime 下没被用到 → `tsc` TS6133。这一条同时炸了 Type Check 和 Bundle Analysis(后者的 `@object-ui/console build` 就是 `tsc && vite build`),两个红叉是同一个根因。 2. 探针的 dataSource 是 `new Proxy({}, …)`。任何 `{...dataSource}` 派生复制的都是 **自有可枚举属性**,而空 target 一个都没有——于是派生出来的源是个空壳,方法全被 悄悄剥掉。`embeddable-form` 恰好这么做(`{...dataSource, create: stub}`,为公开 表单中和写操作),所以它被记成"没到达数据层",而那是探针造的假信号。改成用真实 自有属性播种,Proxy 只兜底未播种的键。 3. 订阅方法(`onMutation`)返回的是**退订函数**,块在卸载时会调用它。探针一律返回 Promise,于是卸载阶段 `unsub is not a function`——又一个与被测块无关的失败。 同时把挂载的 schema 从"objectName + required 输入"改成"每个声明的输入都给一个合理 值",数组给非空。理由是 read 路径可能挂在**可选**输入上:`embeddable-form` 只有在 `config.fields` 非空时才构造它内层 ObjectForm 取数用的只读源;不给就等于没问过它, 却会被读成"没绑上"。数组给 `[]` 是同一个坑的另一面——空 `columns` 会让列表直接渲染 空态,压根不去取数。 `list-view` / `embeddable-form` 的台账条目因此更准了:已验证这两条是接线问题而不是 探针够不着——`embeddable-form` 在同样的挂载下,桥接一存在就立刻 `getObjectSchema`, 不存在就不会。objectui#3144 仍然单独修。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01S3cP1eY1novcNhQEDBrSZD --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 4ae0ac4 commit cc70b8f

4 files changed

Lines changed: 331 additions & 0 deletions

File tree

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
---
2+
"@object-ui/sdui-parser": patch
3+
---
4+
5+
A declared `objectName` must reach the data layer — the evidence the framework's spec↔registry check cannot gather (objectstack#4472).
6+
7+
The framework diffs `sdui.manifest.json` against the spec's zod schemas and, while that
8+
check was named `check:react-conformance`, it was read — by its own file header — as
9+
confirming these components "ACTUALLY implement" the spec's props. It never could. Both
10+
sides of that diff are **declarations**, and this repo produces one of them:
11+
`manifestFromConfigs` copies `config.inputs` verbatim and cannot observe whether the
12+
renderer behind a block reads any of them. So a prop both sides declare and nothing
13+
consumes reads there as agreement — which is how objectstack#4413's four `record:*` blocks
14+
published an `objectName`/`recordId` no renderer read, rendered blank, and stayed green.
15+
16+
Evidence about the render path has to be taken from the render path, so it lives here now.
17+
`apps/console/src/__tests__/public-block-binding-reach.test.tsx` mounts every public block
18+
that declares an `objectName` input through `SchemaRenderer` with nothing but that binding,
19+
under a provider whose `dataSource` is a Proxy recording every call, and asserts some call
20+
carried the object name. Deliberately narrow — "is this binding wired", not "is every
21+
declared input consumed", which is not decidable from outside without heuristics. Every
22+
non-reaching block carries a written reason in a ledger asserted to equal the observed set
23+
in **both** directions, so a block that starts binding forces its entry deleted and a block
24+
that stops binding fails; the suite was verified to go red both ways.
25+
26+
First run: five of eight bound blocks reach the data layer, three do not.
27+
`record:related_list` legitimately declines to fetch without the parent record id from
28+
`RecordContext` (already documented in @objectstack/spec's objectstack#4413 ledger).
29+
`list-view` and `embeddable-form` do not, and that is a real defect of the same shape —
30+
neither registration bridges the schema-renderer context onto the component's `dataSource`
31+
prop the way `object-form` / `object-kanban` / `object-calendar` do, and `SchemaRenderer`
32+
never injects it, so on the registry/SDUI path both render an empty shell while declaring
33+
`objectName` **required**. Filed as objectui#3144 rather than fixed here: giving them a
34+
data source changes what they render everywhere they are mounted bare.
35+
36+
`manifestFromConfigs` and `scripts/dump-public-manifest.mjs` now say in their own docs that
37+
what they emit is what a registration *declared*, never what a renderer reads.
Lines changed: 269 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,269 @@
1+
/**
2+
* ObjectUI
3+
* Copyright (c) 2024-present ObjectStack Inc.
4+
*
5+
* This source code is licensed under the MIT license found in the
6+
* LICENSE file in the root directory of this source tree.
7+
*
8+
* Public blocks — a declared `objectName` binding must REACH THE DATA LAYER
9+
* (objectstack#4472, the detection half of objectstack#4413).
10+
*
11+
* ## Why this exists
12+
*
13+
* The framework's spec↔registry check (`check:react-declaration-parity`)
14+
* compares two DECLARATIONS: the spec zod schema's props on one side, and on
15+
* the other the `inputs` this repo's registry configs declare — copied verbatim
16+
* into `sdui.manifest.json` by `manifestFromConfigs`. Neither side observes a
17+
* renderer. So a prop that both sides declare and NO renderer consumes reads as
18+
* perfect agreement over there, and it did: `record:details` /
19+
* `record:highlights` / `record:related_list` / `record:path` published
20+
* `objectName`+`recordId` that nothing read, four blocks rendered blank, and
21+
* that check stayed green for the whole life of the defect (objectstack#4413).
22+
* It was found by a human reading these renderers.
23+
*
24+
* This test is the evidence that check cannot gather, taken from the only place
25+
* that has it — the render path. It is deliberately narrow: not "is every
26+
* declared input consumed" (undecidable from outside without heuristics) but
27+
* one exact, observable question per block —
28+
*
29+
* mount it through `SchemaRenderer` with a plausible value for every input it
30+
* declares, under a provider whose `dataSource` records every call:
31+
* **did any call carry the object name?**
32+
*
33+
* Every declared input, not just the required ones: a block's read path can be
34+
* gated on an optional one (`embeddable-form` only builds the read-only source
35+
* its inner `ObjectForm` fetches through when `config.fields` is non-empty), and
36+
* omitting it would read here as "does not bind" when the truth is "was never
37+
* asked to". Values are plausible rather than degenerate for the same reason —
38+
* an early `[]` for `columns` makes a list render its empty state without ever
39+
* asking for data.
40+
*
41+
* A block that declares `objectName` and asks the data layer for something else
42+
* — or for nothing at all — is not bound to the object it advertises. That is
43+
* the objectstack#4413 shape, stated behaviourally.
44+
*
45+
* ## Scope, stated so it is not over-read in turn
46+
*
47+
* A reaching call proves the binding is WIRED, not that the block renders
48+
* correctly. And "did not reach" has legitimate causes (a block that needs a
49+
* parent record id first), which is what the ledger below is for — every
50+
* non-reaching block carries a written reason, and the ledger is asserted to
51+
* equal the observed set in BOTH directions, so a block that starts reaching
52+
* must be removed from it and a block that stops reaching fails here.
53+
*/
54+
55+
import { describe, it, expect } from 'vitest';
56+
import { render, act } from '@testing-library/react';
57+
import { ComponentRegistry } from '@object-ui/core';
58+
import { SchemaRenderer, SchemaRendererProvider } from '@object-ui/react';
59+
// The two graphs whose registrations this reads — the layout/content primitives
60+
// and the console's own plugin layer, from the module main.tsx boots from. Same
61+
// posture as public-contract.test.ts: read the REAL registration list, because a
62+
// hand-copied one would agree with itself and tell us nothing.
63+
import '@object-ui/components';
64+
import '../register-plugins';
65+
66+
/** The object name every probed block is bound to; must appear in a data call. */
67+
const PROBE_OBJECT = 'probe_object__c';
68+
69+
/**
70+
* `DataSource` methods the recording stub carries as real own properties, so a
71+
* block that derives a source by spreading (`{...dataSource, …}`) still gets
72+
* them. Not exhaustive and does not need to be — anything unlisted is served by
73+
* the Proxy's `get` — it only needs to cover what survives a spread.
74+
*/
75+
const DATA_SOURCE_METHODS = [
76+
'find',
77+
'findOne',
78+
'create',
79+
'update',
80+
'delete',
81+
'aggregate',
82+
'count',
83+
'getObjectSchema',
84+
'getObjects',
85+
'getView',
86+
'listViews',
87+
'listViewOverrides',
88+
'updateViewConfig',
89+
'onMutation',
90+
] as const;
91+
92+
/**
93+
* Blocks that declare an `objectName` input and do NOT reach the data layer
94+
* with it, each with the reason. Entries are debt, not acceptance — an entry
95+
* whose block starts reaching fails this test until it is deleted.
96+
*/
97+
const NO_DATA_REACH: Readonly<Record<string, string>> = {
98+
// Legitimate, and documented on the framework side (@objectstack/spec
99+
// react-blocks.ts, the objectstack#4413 exclusion ledger): this block renders
100+
// a CHILD list scoped to a parent record, and takes that parent from the
101+
// record page's shared record context. Mounted with no record bound there is
102+
// no parent id, so it correctly declines to fetch rather than listing the
103+
// whole child object. `objectName` IS read — it names the related object and
104+
// titles the panel.
105+
'record:related_list':
106+
'needs the parent record id from RecordContext before it may fetch; declines to fetch without one (objectstack#4413 ledger)',
107+
108+
// Both of these are the SAME defect, and it is a real one — debt recorded
109+
// here, not divergence accepted. Neither registration bridges the
110+
// schema-renderer context onto the component's `dataSource` PROP:
111+
// `object-form`, `object-kanban` and `object-calendar` each register a small
112+
// renderer that does exactly that, `list-view` is registered as the bare
113+
// `ListView` (which reads `props.dataSource`), and `embeddable-form`'s
114+
// renderer is `({schema}) => <EmbeddableForm config={schema} />`, which drops
115+
// it. `SchemaRenderer` never injects `dataSource` into props, so on the
116+
// registry/SDUI path both render an empty shell while declaring `objectName`
117+
// **required** — the objectstack#4413 shape, one layer up.
118+
//
119+
// Verified to be the wiring and not this probe's reach: `embeddable-form`
120+
// fetches the moment the bridge exists (its inner `ObjectForm` calls
121+
// `getObjectSchema` through the read-only source it derives), and does not
122+
// without it, on the identical mount.
123+
//
124+
// Filed rather than fixed alongside this suite: giving these two a data source
125+
// changes what they render everywhere they are mounted bare, which wants its
126+
// own review — objectui#3144. When it lands, the assertions below FORCE these
127+
// two entries deleted; a ledger nobody must update is how an accepted baseline
128+
// starts.
129+
'list-view':
130+
'registered bare; ListView reads props.dataSource and SchemaRenderer never injects it — objectui#3144',
131+
'embeddable-form':
132+
'renderer drops the context dataSource (`<EmbeddableForm config={schema} />`) — objectui#3144',
133+
};
134+
135+
/** Does this config declare an `objectName` input? */
136+
const declaresObjectName = (cfg: { inputs?: Array<{ name?: string }> }) =>
137+
(cfg.inputs ?? []).some((i) => i?.name === 'objectName');
138+
139+
/**
140+
* A plausible value for one declared input.
141+
*
142+
* "Plausible", not "present": arrays are non-empty because an empty `columns` is
143+
* a config a block can legitimately short-circuit on, and a block that renders
144+
* its empty state without asking for data would read here as an unbound
145+
* binding.
146+
*/
147+
const sampleFor = (input: any): unknown => {
148+
if (input.name === 'objectName') return PROBE_OBJECT;
149+
if (input.defaultValue !== undefined) return input.defaultValue;
150+
switch (input.type) {
151+
case 'number':
152+
return 1;
153+
case 'boolean':
154+
return true;
155+
case 'array':
156+
return ['name'];
157+
case 'object':
158+
return {};
159+
case 'enum': {
160+
const first = input.enum?.[0];
161+
return typeof first === 'object' && first !== null ? first.value : (first ?? 'x');
162+
}
163+
default:
164+
return input.name === 'recordId' ? 'probe-record-1' : 'x';
165+
}
166+
};
167+
168+
/**
169+
* Mount one block bare and report every data-layer call it made.
170+
*
171+
* The data source is a Proxy so ANY method a block reaches for is recorded
172+
* rather than crashing it — a block that calls `dataSource.aggregate` must not
173+
* fail the probe merely because a hand-written stub didn't anticipate it.
174+
*/
175+
async function dataCallsFor(cfg: any): Promise<string[]> {
176+
const calls: string[] = [];
177+
const record = (key: string) =>
178+
(...args: unknown[]) => {
179+
calls.push(`${key}(${args.map((a) => JSON.stringify(a) ?? 'undefined').join(', ')})`);
180+
// Subscription methods hand back an UNSUBSCRIBE function, which the block
181+
// calls on unmount. Returning a promise for those crashes the teardown
182+
// (`unsub is not a function`) — a failure that says nothing about the
183+
// block.
184+
return /^on[A-Z]/.test(key) || key === 'subscribe' ? () => {} : Promise.resolve([]);
185+
};
186+
// Seeded with real own properties, not a bare Proxy target: a block may hand
187+
// its own children a DERIVED source (`{...dataSource, create: stub}` — that is
188+
// exactly what `embeddable-form` does to neutralise writes on a public form),
189+
// and spreading a Proxy over `{}` copies nothing, silently stripping every
190+
// method. The Proxy still answers anything unseeded, so a block reaching for a
191+
// method not listed here is recorded rather than crashing.
192+
const seeded: Record<string, unknown> = {};
193+
for (const m of DATA_SOURCE_METHODS) seeded[m] = record(m);
194+
const dataSource: any = new Proxy(seeded, {
195+
get: (target, key: string) => (key in target ? (target as any)[key] : record(key)),
196+
});
197+
198+
// Only `objectName` + the inputs the block declares REQUIRED. A bogus value
199+
// for an optional input is a degenerate config that can make a block bail out
200+
// before it ever asks for data — which would read here as a false finding.
201+
// EVERY declared input, not just the required ones — see the file header: a
202+
// read path can be gated on an optional input, and leaving it out would report
203+
// a block as unbound when it was simply never asked to fetch.
204+
const schema: Record<string, unknown> = { type: cfg.type };
205+
for (const input of cfg.inputs ?? []) {
206+
schema[input.name] = sampleFor(input);
207+
}
208+
209+
const view = render(
210+
<SchemaRendererProvider dataSource={dataSource}>
211+
<SchemaRenderer schema={schema as any} />
212+
</SchemaRendererProvider>,
213+
);
214+
// Settle: a block may fetch from an effect, after a lazy renderer resolves, or
215+
// in a second pass once the object schema lands.
216+
for (let i = 0; i < 10; i++) {
217+
await act(async () => {
218+
await new Promise((resolve) => setTimeout(resolve, 50));
219+
});
220+
}
221+
view.unmount();
222+
return calls;
223+
}
224+
225+
const candidates = ComponentRegistry.getPublicConfigs().filter(declaresObjectName);
226+
227+
describe('public blocks — a declared objectName reaches the data layer (objectstack#4472)', () => {
228+
it('finds the object-bound blocks to probe (guards against probing nothing)', () => {
229+
// If the registry ever stops exposing these, this suite would pass by
230+
// probing an empty list — the failure mode a coverage gate must not have.
231+
expect(candidates.length).toBeGreaterThan(0);
232+
expect(candidates.map((c) => c.type)).toContain('object-form');
233+
});
234+
235+
for (const cfg of candidates) {
236+
const ledgered = cfg.type in NO_DATA_REACH;
237+
it(`${cfg.type} ${ledgered ? 'does not reach the data layer (ledgered)' : 'asks the data layer for its objectName'}`, async () => {
238+
const calls = await dataCallsFor(cfg);
239+
const reached = calls.filter((c) => c.includes(PROBE_OBJECT));
240+
if (ledgered) {
241+
// Asserted, not skipped: the day this block starts binding, this fails
242+
// and the ledger entry has to go — a ledger nobody is forced to update
243+
// decays into the accepted-baseline problem this whole test exists for.
244+
expect(reached, `${cfg.type} now reaches the data layer — delete its NO_DATA_REACH entry`).toEqual([]);
245+
} else {
246+
expect(
247+
reached.length,
248+
`<${cfg.type}> declares an \`objectName\` input but made no data call naming "${PROBE_OBJECT}".\n` +
249+
`Calls observed: ${calls.length ? calls.join(' | ') : '(none)'}\n` +
250+
'Either the binding does not reach the renderer (the objectstack#4413 shape — fix the wiring),\n' +
251+
'or the block legitimately cannot fetch yet: add it to NO_DATA_REACH with the reason.',
252+
).toBeGreaterThan(0);
253+
}
254+
}, 30_000);
255+
}
256+
257+
it('the ledger names only blocks that really do not reach — no stale entries', () => {
258+
const unknown = Object.keys(NO_DATA_REACH).filter(
259+
(type) => !candidates.some((c) => c.type === type),
260+
);
261+
expect(
262+
unknown,
263+
'NO_DATA_REACH lists blocks that no longer declare an `objectName` input — delete them',
264+
).toEqual([]);
265+
for (const [type, reason] of Object.entries(NO_DATA_REACH)) {
266+
expect(reason.length, `${type} needs a written reason, not an empty one`).toBeGreaterThan(20);
267+
}
268+
});
269+
});

packages/sdui-parser/src/index.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,23 @@ const INPUT_TYPES = new Set([
114114
'slot',
115115
]);
116116

117+
/**
118+
* Project registry configs into the SDUI manifest.
119+
*
120+
* ⚠️ Every field here is copied from what the registration **declared**. In
121+
* particular `inputs` is `config.inputs` verbatim — this function does not, and
122+
* cannot, observe whether the renderer behind the block reads any of them.
123+
*
124+
* Worth stating because of what consumes the output. The framework's
125+
* `check:react-blocks-declaration-parity` diffs this against the spec's zod
126+
* schemas, and while that check was named `check:react-conformance` it was read
127+
* — by its own file header — as confirming the components "ACTUALLY implement"
128+
* the spec's props. It never could: both sides of that diff are declarations,
129+
* and this is the side this file produces. Four blocks published an `objectName`
130+
* no renderer read and sailed through it green (objectstack#4413; corrected in
131+
* objectstack#4472). Evidence about the render path has to come from the render
132+
* path — see `apps/console/src/__tests__/public-block-binding-reach.test.tsx`.
133+
*/
117134
export function manifestFromConfigs(
118135
configs: RegistryConfigLike[],
119136
opts: { only?: Set<string>; publicOnly?: boolean } = {},

scripts/dump-public-manifest.mjs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,13 @@
11
// ADR-0080: generate the public-tier SDUI component manifest.
22
//
3+
// The manifest records what each registration DECLARES (`config.inputs`, copied
4+
// verbatim by `manifestFromConfigs`), never what a renderer reads. The framework
5+
// diffs it against the spec schemas — a comparison of two declarations, so a
6+
// prop both sides declare and nothing consumes reads there as agreement
7+
// (objectstack#4413 shipped through exactly that; objectstack#4472 renamed the
8+
// check to `check:react-declaration-parity` to stop it reading as proof of
9+
// implementation).
10+
//
311
// The registry is a browser app (plugin-map/charts pull browser-only deps), so
412
// the reliable way to enumerate it is in a real browser: load the built
513
// `manifest-dump.html` (which registers everything the console does and exposes

0 commit comments

Comments
 (0)