From f911a5e2d9a187d0c66c5ac646fd8a38326a4e39 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 1 Aug 2026 10:19:14 +0000 Subject: [PATCH 1/2] =?UTF-8?q?test(console,sdui-parser):=20public=20block?= =?UTF-8?q?=20=E5=A3=B0=E6=98=8E=E7=9A=84=20objectName=20=E5=BF=85?= =?UTF-8?q?=E9=A1=BB=E7=9C=9F=E7=9A=84=E5=88=B0=E8=BE=BE=E6=95=B0=E6=8D=AE?= =?UTF-8?q?=E5=B1=82=20(objectstack#4472)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01S3cP1eY1novcNhQEDBrSZD --- .changeset/public-block-binding-reach.md | 37 +++ .../public-block-binding-reach.test.tsx | 211 ++++++++++++++++++ packages/sdui-parser/src/index.ts | 17 ++ scripts/dump-public-manifest.mjs | 8 + 4 files changed, 273 insertions(+) create mode 100644 .changeset/public-block-binding-reach.md create mode 100644 apps/console/src/__tests__/public-block-binding-reach.test.tsx diff --git a/.changeset/public-block-binding-reach.md b/.changeset/public-block-binding-reach.md new file mode 100644 index 000000000..aa711dc92 --- /dev/null +++ b/.changeset/public-block-binding-reach.md @@ -0,0 +1,37 @@ +--- +"@object-ui/sdui-parser": patch +--- + +A declared `objectName` must reach the data layer — the evidence the framework's spec↔registry check cannot gather (objectstack#4472). + +The framework diffs `sdui.manifest.json` against the spec's zod schemas and, while that +check was named `check:react-conformance`, it was read — by its own file header — as +confirming these components "ACTUALLY implement" the spec's props. It never could. Both +sides of that diff are **declarations**, and this repo produces one of them: +`manifestFromConfigs` copies `config.inputs` verbatim and cannot observe whether the +renderer behind a block reads any of them. So a prop both sides declare and nothing +consumes reads there as agreement — which is how objectstack#4413's four `record:*` blocks +published an `objectName`/`recordId` no renderer read, rendered blank, and stayed green. + +Evidence about the render path has to be taken from the render path, so it lives here now. +`apps/console/src/__tests__/public-block-binding-reach.test.tsx` mounts every public block +that declares an `objectName` input through `SchemaRenderer` with nothing but that binding, +under a provider whose `dataSource` is a Proxy recording every call, and asserts some call +carried the object name. Deliberately narrow — "is this binding wired", not "is every +declared input consumed", which is not decidable from outside without heuristics. Every +non-reaching block carries a written reason in a ledger asserted to equal the observed set +in **both** directions, so a block that starts binding forces its entry deleted and a block +that stops binding fails; the suite was verified to go red both ways. + +First run: five of eight bound blocks reach the data layer, three do not. +`record:related_list` legitimately declines to fetch without the parent record id from +`RecordContext` (already documented in @objectstack/spec's objectstack#4413 ledger). +`list-view` and `embeddable-form` do not, and that is a real defect of the same shape — +neither registration bridges the schema-renderer context onto the component's `dataSource` +prop the way `object-form` / `object-kanban` / `object-calendar` do, and `SchemaRenderer` +never injects it, so on the registry/SDUI path both render an empty shell while declaring +`objectName` **required**. Filed as objectui#3144 rather than fixed here: giving them a +data source changes what they render everywhere they are mounted bare. + +`manifestFromConfigs` and `scripts/dump-public-manifest.mjs` now say in their own docs that +what they emit is what a registration *declared*, never what a renderer reads. diff --git a/apps/console/src/__tests__/public-block-binding-reach.test.tsx b/apps/console/src/__tests__/public-block-binding-reach.test.tsx new file mode 100644 index 000000000..6f25b3b34 --- /dev/null +++ b/apps/console/src/__tests__/public-block-binding-reach.test.tsx @@ -0,0 +1,211 @@ +/** + * 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. + * + * Public blocks — a declared `objectName` binding must REACH THE DATA LAYER + * (objectstack#4472, the detection half of objectstack#4413). + * + * ## Why this exists + * + * The framework's spec↔registry check (`check:react-declaration-parity`) + * compares two DECLARATIONS: the spec zod schema's props on one side, and on + * the other the `inputs` this repo's registry configs declare — copied verbatim + * into `sdui.manifest.json` by `manifestFromConfigs`. Neither side observes a + * renderer. So a prop that both sides declare and NO renderer consumes reads as + * perfect agreement over there, and it did: `record:details` / + * `record:highlights` / `record:related_list` / `record:path` published + * `objectName`+`recordId` that nothing read, four blocks rendered blank, and + * that check stayed green for the whole life of the defect (objectstack#4413). + * It was found by a human reading these renderers. + * + * This test is the evidence that check cannot gather, taken from the only place + * that has it — the render path. It is deliberately narrow: not "is every + * declared input consumed" (undecidable from outside without heuristics) but + * one exact, observable question per block — + * + * mount it through `SchemaRenderer` with nothing but its declared + * `objectName`, under a provider whose `dataSource` records every call: + * **did any call carry that object name?** + * + * A block that declares `objectName` and asks the data layer for something else + * — or for nothing at all — is not bound to the object it advertises. That is + * the objectstack#4413 shape, stated behaviourally. + * + * ## Scope, stated so it is not over-read in turn + * + * A reaching call proves the binding is WIRED, not that the block renders + * correctly. And "did not reach" has legitimate causes (a block that needs a + * parent record id first), which is what the ledger below is for — every + * non-reaching block carries a written reason, and the ledger is asserted to + * equal the observed set in BOTH directions, so a block that starts reaching + * must be removed from it and a block that stops reaching fails here. + */ + +import { describe, it, expect } from 'vitest'; +import React from 'react'; +import { render, act } from '@testing-library/react'; +import { ComponentRegistry } from '@object-ui/core'; +import { SchemaRenderer, SchemaRendererProvider } from '@object-ui/react'; +// The two graphs whose registrations this reads — the layout/content primitives +// and the console's own plugin layer, from the module main.tsx boots from. Same +// posture as public-contract.test.ts: read the REAL registration list, because a +// hand-copied one would agree with itself and tell us nothing. +import '@object-ui/components'; +import '../register-plugins'; + +/** The object name every probed block is bound to; must appear in a data call. */ +const PROBE_OBJECT = 'probe_object__c'; + +/** + * Blocks that declare an `objectName` input and do NOT reach the data layer + * with it, each with the reason. Entries are debt, not acceptance — an entry + * whose block starts reaching fails this test until it is deleted. + */ +const NO_DATA_REACH: Readonly> = { + // Legitimate, and documented on the framework side (@objectstack/spec + // react-blocks.ts, the objectstack#4413 exclusion ledger): this block renders + // a CHILD list scoped to a parent record, and takes that parent from the + // record page's shared record context. Mounted with no record bound there is + // no parent id, so it correctly declines to fetch rather than listing the + // whole child object. `objectName` IS read — it names the related object and + // titles the panel. + 'record:related_list': + 'needs the parent record id from RecordContext before it may fetch; declines to fetch without one (objectstack#4413 ledger)', + + // Both of these are the SAME defect, and it is a real one: the registry entry + // has no wrapper bridging the schema-renderer context onto the component's + // `dataSource` PROP. `object-form`, `object-kanban` and `object-calendar` each + // register a small renderer that does exactly that bridge; `list-view` is + // registered as the bare `ListView` (which reads `props.dataSource`), and + // `embeddable-form`'s renderer is `({schema}) => ` + // (which drops it). `SchemaRenderer` never injects `dataSource` into props, so + // on the registry/SDUI path both blocks silently render an empty shell while + // declaring `objectName` **required**. + // + // Filed rather than fixed here: giving these two a data source changes what + // they render everywhere they are mounted bare, which is a change that wants + // its own review — see objectui#3144. + 'list-view': + 'registered bare; ListView reads props.dataSource and SchemaRenderer never injects it — objectui#3144', + 'embeddable-form': + 'renderer drops the context dataSource (``) — objectui#3144', +}; + +/** Does this config declare an `objectName` input? */ +const declaresObjectName = (cfg: { inputs?: Array<{ name?: string }> }) => + (cfg.inputs ?? []).some((i) => i?.name === 'objectName'); + +/** Plausible value for a required input, so nothing short-circuits on a missing one. */ +const sampleFor = (input: any): unknown => { + if (input.name === 'objectName') return PROBE_OBJECT; + if (input.defaultValue !== undefined) return input.defaultValue; + switch (input.type) { + case 'number': + return 1; + case 'boolean': + return true; + case 'array': + return []; + case 'object': + return {}; + case 'enum': { + const first = input.enum?.[0]; + return typeof first === 'object' && first !== null ? first.value : (first ?? 'x'); + } + default: + return input.name === 'recordId' ? 'probe-record-1' : 'x'; + } +}; + +/** + * Mount one block bare and report every data-layer call it made. + * + * The data source is a Proxy so ANY method a block reaches for is recorded + * rather than crashing it — a block that calls `dataSource.aggregate` must not + * fail the probe merely because a hand-written stub didn't anticipate it. + */ +async function dataCallsFor(cfg: any): Promise { + const calls: string[] = []; + const dataSource: any = new Proxy( + {}, + { + get: (_t, key: string) => + (...args: unknown[]) => { + calls.push(`${key}(${args.map((a) => JSON.stringify(a) ?? 'undefined').join(', ')})`); + return Promise.resolve([]); + }, + }, + ); + + // Only `objectName` + the inputs the block declares REQUIRED. A bogus value + // for an optional input is a degenerate config that can make a block bail out + // before it ever asks for data — which would read here as a false finding. + const schema: Record = { type: cfg.type }; + for (const input of cfg.inputs ?? []) { + if (input?.name === 'objectName' || input?.required) schema[input.name] = sampleFor(input); + } + + const view = render( + + + , + ); + // Settle: a block may fetch from an effect, after a lazy renderer resolves, or + // in a second pass once the object schema lands. + for (let i = 0; i < 10; i++) { + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 50)); + }); + } + view.unmount(); + return calls; +} + +const candidates = ComponentRegistry.getPublicConfigs().filter(declaresObjectName); + +describe('public blocks — a declared objectName reaches the data layer (objectstack#4472)', () => { + it('finds the object-bound blocks to probe (guards against probing nothing)', () => { + // If the registry ever stops exposing these, this suite would pass by + // probing an empty list — the failure mode a coverage gate must not have. + expect(candidates.length).toBeGreaterThan(0); + expect(candidates.map((c) => c.type)).toContain('object-form'); + }); + + for (const cfg of candidates) { + const ledgered = cfg.type in NO_DATA_REACH; + it(`${cfg.type} ${ledgered ? 'does not reach the data layer (ledgered)' : 'asks the data layer for its objectName'}`, async () => { + const calls = await dataCallsFor(cfg); + const reached = calls.filter((c) => c.includes(PROBE_OBJECT)); + if (ledgered) { + // Asserted, not skipped: the day this block starts binding, this fails + // and the ledger entry has to go — a ledger nobody is forced to update + // decays into the accepted-baseline problem this whole test exists for. + expect(reached, `${cfg.type} now reaches the data layer — delete its NO_DATA_REACH entry`).toEqual([]); + } else { + expect( + reached.length, + `<${cfg.type}> declares an \`objectName\` input but made no data call naming "${PROBE_OBJECT}".\n` + + `Calls observed: ${calls.length ? calls.join(' | ') : '(none)'}\n` + + 'Either the binding does not reach the renderer (the objectstack#4413 shape — fix the wiring),\n' + + 'or the block legitimately cannot fetch yet: add it to NO_DATA_REACH with the reason.', + ).toBeGreaterThan(0); + } + }, 30_000); + } + + it('the ledger names only blocks that really do not reach — no stale entries', () => { + const unknown = Object.keys(NO_DATA_REACH).filter( + (type) => !candidates.some((c) => c.type === type), + ); + expect( + unknown, + 'NO_DATA_REACH lists blocks that no longer declare an `objectName` input — delete them', + ).toEqual([]); + for (const [type, reason] of Object.entries(NO_DATA_REACH)) { + expect(reason.length, `${type} needs a written reason, not an empty one`).toBeGreaterThan(20); + } + }); +}); diff --git a/packages/sdui-parser/src/index.ts b/packages/sdui-parser/src/index.ts index 4e7ae010e..09ea7409f 100644 --- a/packages/sdui-parser/src/index.ts +++ b/packages/sdui-parser/src/index.ts @@ -114,6 +114,23 @@ const INPUT_TYPES = new Set([ 'slot', ]); +/** + * Project registry configs into the SDUI manifest. + * + * ⚠️ Every field here is copied from what the registration **declared**. In + * particular `inputs` is `config.inputs` verbatim — this function does not, and + * cannot, observe whether the renderer behind the block reads any of them. + * + * Worth stating because of what consumes the output. The framework's + * `check:react-blocks-declaration-parity` diffs this against the spec's zod + * schemas, and while that check was named `check:react-conformance` it was read + * — by its own file header — as confirming the components "ACTUALLY implement" + * the spec's props. It never could: both sides of that diff are declarations, + * and this is the side this file produces. Four blocks published an `objectName` + * no renderer read and sailed through it green (objectstack#4413; corrected in + * objectstack#4472). Evidence about the render path has to come from the render + * path — see `apps/console/src/__tests__/public-block-binding-reach.test.tsx`. + */ export function manifestFromConfigs( configs: RegistryConfigLike[], opts: { only?: Set; publicOnly?: boolean } = {}, diff --git a/scripts/dump-public-manifest.mjs b/scripts/dump-public-manifest.mjs index 13c54a96c..6438c37ed 100644 --- a/scripts/dump-public-manifest.mjs +++ b/scripts/dump-public-manifest.mjs @@ -1,5 +1,13 @@ // ADR-0080: generate the public-tier SDUI component manifest. // +// The manifest records what each registration DECLARES (`config.inputs`, copied +// verbatim by `manifestFromConfigs`), never what a renderer reads. The framework +// diffs it against the spec schemas — a comparison of two declarations, so a +// prop both sides declare and nothing consumes reads there as agreement +// (objectstack#4413 shipped through exactly that; objectstack#4472 renamed the +// check to `check:react-declaration-parity` to stop it reading as proof of +// implementation). +// // The registry is a browser app (plugin-map/charts pull browser-only deps), so // the reliable way to enumerate it is in a real browser: load the built // `manifest-dump.html` (which registers everything the console does and exposes From 7995d6dea6e9a640068b1737f878389e343b883d Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 1 Aug 2026 10:31:09 +0000 Subject: [PATCH 2/2] =?UTF-8?q?fix(console):=20=E4=BF=AE=E6=8E=89=E6=9C=AA?= =?UTF-8?q?=E4=BD=BF=E7=94=A8=E7=9A=84=20React=20import=EF=BC=8C=E5=B9=B6?= =?UTF-8?q?=E8=AE=A9=E6=8E=A2=E9=92=88=E4=B8=8D=E5=86=8D=E8=87=AA=E9=80=A0?= =?UTF-8?q?=E5=81=87=E4=BF=A1=E5=8F=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 三处,都是上一提交暴露出来的: 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 Claude-Session: https://claude.ai/code/session_01S3cP1eY1novcNhQEDBrSZD --- .../public-block-binding-reach.test.tsx | 116 +++++++++++++----- 1 file changed, 87 insertions(+), 29 deletions(-) diff --git a/apps/console/src/__tests__/public-block-binding-reach.test.tsx b/apps/console/src/__tests__/public-block-binding-reach.test.tsx index 6f25b3b34..d3635a5ed 100644 --- a/apps/console/src/__tests__/public-block-binding-reach.test.tsx +++ b/apps/console/src/__tests__/public-block-binding-reach.test.tsx @@ -26,9 +26,17 @@ * declared input consumed" (undecidable from outside without heuristics) but * one exact, observable question per block — * - * mount it through `SchemaRenderer` with nothing but its declared - * `objectName`, under a provider whose `dataSource` records every call: - * **did any call carry that object name?** + * mount it through `SchemaRenderer` with a plausible value for every input it + * declares, under a provider whose `dataSource` records every call: + * **did any call carry the object name?** + * + * Every declared input, not just the required ones: a block's read path can be + * gated on an optional one (`embeddable-form` only builds the read-only source + * its inner `ObjectForm` fetches through when `config.fields` is non-empty), and + * omitting it would read here as "does not bind" when the truth is "was never + * asked to". Values are plausible rather than degenerate for the same reason — + * an early `[]` for `columns` makes a list render its empty state without ever + * asking for data. * * A block that declares `objectName` and asks the data layer for something else * — or for nothing at all — is not bound to the object it advertises. That is @@ -45,7 +53,6 @@ */ import { describe, it, expect } from 'vitest'; -import React from 'react'; import { render, act } from '@testing-library/react'; import { ComponentRegistry } from '@object-ui/core'; import { SchemaRenderer, SchemaRendererProvider } from '@object-ui/react'; @@ -59,6 +66,29 @@ import '../register-plugins'; /** The object name every probed block is bound to; must appear in a data call. */ const PROBE_OBJECT = 'probe_object__c'; +/** + * `DataSource` methods the recording stub carries as real own properties, so a + * block that derives a source by spreading (`{...dataSource, …}`) still gets + * them. Not exhaustive and does not need to be — anything unlisted is served by + * the Proxy's `get` — it only needs to cover what survives a spread. + */ +const DATA_SOURCE_METHODS = [ + 'find', + 'findOne', + 'create', + 'update', + 'delete', + 'aggregate', + 'count', + 'getObjectSchema', + 'getObjects', + 'getView', + 'listViews', + 'listViewOverrides', + 'updateViewConfig', + 'onMutation', +] as const; + /** * Blocks that declare an `objectName` input and do NOT reach the data layer * with it, each with the reason. Entries are debt, not acceptance — an entry @@ -75,19 +105,27 @@ const NO_DATA_REACH: Readonly> = { 'record:related_list': 'needs the parent record id from RecordContext before it may fetch; declines to fetch without one (objectstack#4413 ledger)', - // Both of these are the SAME defect, and it is a real one: the registry entry - // has no wrapper bridging the schema-renderer context onto the component's - // `dataSource` PROP. `object-form`, `object-kanban` and `object-calendar` each - // register a small renderer that does exactly that bridge; `list-view` is - // registered as the bare `ListView` (which reads `props.dataSource`), and - // `embeddable-form`'s renderer is `({schema}) => ` - // (which drops it). `SchemaRenderer` never injects `dataSource` into props, so - // on the registry/SDUI path both blocks silently render an empty shell while - // declaring `objectName` **required**. + // Both of these are the SAME defect, and it is a real one — debt recorded + // here, not divergence accepted. Neither registration bridges the + // schema-renderer context onto the component's `dataSource` PROP: + // `object-form`, `object-kanban` and `object-calendar` each register a small + // renderer that does exactly that, `list-view` is registered as the bare + // `ListView` (which reads `props.dataSource`), and `embeddable-form`'s + // renderer is `({schema}) => `, which drops + // it. `SchemaRenderer` never injects `dataSource` into props, so on the + // registry/SDUI path both render an empty shell while declaring `objectName` + // **required** — the objectstack#4413 shape, one layer up. + // + // Verified to be the wiring and not this probe's reach: `embeddable-form` + // fetches the moment the bridge exists (its inner `ObjectForm` calls + // `getObjectSchema` through the read-only source it derives), and does not + // without it, on the identical mount. // - // Filed rather than fixed here: giving these two a data source changes what - // they render everywhere they are mounted bare, which is a change that wants - // its own review — see objectui#3144. + // Filed rather than fixed alongside this suite: giving these two a data source + // changes what they render everywhere they are mounted bare, which wants its + // own review — objectui#3144. When it lands, the assertions below FORCE these + // two entries deleted; a ledger nobody must update is how an accepted baseline + // starts. 'list-view': 'registered bare; ListView reads props.dataSource and SchemaRenderer never injects it — objectui#3144', 'embeddable-form': @@ -98,7 +136,14 @@ const NO_DATA_REACH: Readonly> = { const declaresObjectName = (cfg: { inputs?: Array<{ name?: string }> }) => (cfg.inputs ?? []).some((i) => i?.name === 'objectName'); -/** Plausible value for a required input, so nothing short-circuits on a missing one. */ +/** + * A plausible value for one declared input. + * + * "Plausible", not "present": arrays are non-empty because an empty `columns` is + * a config a block can legitimately short-circuit on, and a block that renders + * its empty state without asking for data would read here as an unbound + * binding. + */ const sampleFor = (input: any): unknown => { if (input.name === 'objectName') return PROBE_OBJECT; if (input.defaultValue !== undefined) return input.defaultValue; @@ -108,7 +153,7 @@ const sampleFor = (input: any): unknown => { case 'boolean': return true; case 'array': - return []; + return ['name']; case 'object': return {}; case 'enum': { @@ -129,23 +174,36 @@ const sampleFor = (input: any): unknown => { */ async function dataCallsFor(cfg: any): Promise { const calls: string[] = []; - const dataSource: any = new Proxy( - {}, - { - get: (_t, key: string) => - (...args: unknown[]) => { - calls.push(`${key}(${args.map((a) => JSON.stringify(a) ?? 'undefined').join(', ')})`); - return Promise.resolve([]); - }, - }, - ); + const record = (key: string) => + (...args: unknown[]) => { + calls.push(`${key}(${args.map((a) => JSON.stringify(a) ?? 'undefined').join(', ')})`); + // Subscription methods hand back an UNSUBSCRIBE function, which the block + // calls on unmount. Returning a promise for those crashes the teardown + // (`unsub is not a function`) — a failure that says nothing about the + // block. + return /^on[A-Z]/.test(key) || key === 'subscribe' ? () => {} : Promise.resolve([]); + }; + // Seeded with real own properties, not a bare Proxy target: a block may hand + // its own children a DERIVED source (`{...dataSource, create: stub}` — that is + // exactly what `embeddable-form` does to neutralise writes on a public form), + // and spreading a Proxy over `{}` copies nothing, silently stripping every + // method. The Proxy still answers anything unseeded, so a block reaching for a + // method not listed here is recorded rather than crashing. + const seeded: Record = {}; + for (const m of DATA_SOURCE_METHODS) seeded[m] = record(m); + const dataSource: any = new Proxy(seeded, { + get: (target, key: string) => (key in target ? (target as any)[key] : record(key)), + }); // Only `objectName` + the inputs the block declares REQUIRED. A bogus value // for an optional input is a degenerate config that can make a block bail out // before it ever asks for data — which would read here as a false finding. + // EVERY declared input, not just the required ones — see the file header: a + // read path can be gated on an optional input, and leaving it out would report + // a block as unbound when it was simply never asked to fetch. const schema: Record = { type: cfg.type }; for (const input of cfg.inputs ?? []) { - if (input?.name === 'objectName' || input?.required) schema[input.name] = sampleFor(input); + schema[input.name] = sampleFor(input); } const view = render(