|
| 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 | +}); |
0 commit comments