Skip to content

Commit 475afcf

Browse files
os-zhuangclaude
andauthored
test(components): pin the kind:'react' page scope contract (#2951)
A kind:'react' page's `source` is a string compiled at runtime, so the public data blocks it uses (<ListView>, <ObjectForm>) are injected into the evaluated scope by buildComponentScope rather than imported by the authoring file. None of that is statically analysable: a regression — a tag dropped from PUBLIC_BLOCKS, or flipped to isContainer — would silently empty every react page's scope while type-check, lint and build all stayed green. The same invisibility makes the tier read as broken to static review, which is how this was found. Pins three layers: eligibility (list-view/object-form stay public and non-container), the mechanism (no import needed, flat props fold into the block schema, function props and useAdapter survive), and a negative control so the first two cannot pass vacuously. Uses registered stand-ins rather than the real plugin-list/plugin-form — packages/components sits below the plugins in the dependency graph, and the unit under test is the scope builder, not the blocks. Stays in the light `dom` project; no heavyDomTests entry needed. Verified by mutation: removing 'list-view' from PUBLIC_BLOCKS turns three of the six red with `ReferenceError: ListView is not defined`. Co-authored-by: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Co-authored-by: Claude <noreply@anthropic.com>
1 parent ce08d55 commit 475afcf

1 file changed

Lines changed: 180 additions & 0 deletions

File tree

Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,180 @@
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+
* Contract guard for the `kind:'react'` page tier (ADR-0080).
9+
*
10+
* A react page's `source` is a STRING compiled at runtime, so nothing inside it
11+
* is statically analysable. The public data blocks (`<ListView>`,
12+
* `<ObjectForm>`, …) are injected into the evaluated scope by
13+
* `buildComponentScope` (renderers/layout/react-page.tsx) rather than imported
14+
* by the authoring file — which is exactly why this tier keeps being mis-read
15+
* as broken, and why a regression here would be invisible: drop a tag from
16+
* PUBLIC_BLOCKS or flip it to `isContainer` and it silently vanishes from every
17+
* react page's scope while type-check, lint and build all stay green.
18+
*
19+
* These tests pin the three halves of that contract:
20+
* 1. `list-view` / `object-form` stay eligible for injection;
21+
* 2. an author writes `<ListView …/>` with FLAT props and no import, and the
22+
* wrapper folds them into the block's `schema`;
23+
* 3. an identifier that is genuinely absent fails LOUDLY — the negative
24+
* control that keeps (2) from passing vacuously.
25+
*
26+
* Registered stand-ins are used instead of the real plugin-list / plugin-form:
27+
* `packages/components` sits BELOW the plugins in the dependency graph, and the
28+
* unit under test is the scope builder, not the blocks themselves.
29+
*/
30+
31+
import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest';
32+
import { render, waitFor } from '@testing-library/react';
33+
import React from 'react';
34+
import { ComponentRegistry } from '@object-ui/core';
35+
import { SchemaRenderer, AdapterCtx } from '@object-ui/react';
36+
37+
/** Props the stand-in blocks last received, for flat-prop assertions. */
38+
const captured: { listView?: any; objectForm?: any } = {};
39+
40+
const adapter = { find: async () => [], getObjectSchema: async () => ({ name: 'showcase_project', fields: {} }) } as any;
41+
42+
function renderReactPage(source: string) {
43+
return render(
44+
<AdapterCtx.Provider value={adapter}>
45+
<SchemaRenderer schema={{ type: 'home', kind: 'react', name: 'test_page', source }} />
46+
</AdapterCtx.Provider>,
47+
);
48+
}
49+
50+
beforeAll(async () => {
51+
// Registers PageRenderer for `type:'home'`, which dispatches kind:'react'.
52+
await import('../renderers');
53+
54+
// Stand-ins under the REAL curated tags. `getPublicConfigs()` walks
55+
// PUBLIC_BLOCKS and resolves each tag, so registering the bare tag is enough
56+
// to make it public — no `tier` flag needed. Neither tag is registered by
57+
// @object-ui/components, so nothing is being clobbered.
58+
ComponentRegistry.register('list-view', (props: any) => {
59+
captured.listView = props;
60+
return <div data-testid="list-view-double" />;
61+
});
62+
ComponentRegistry.register('object-form', (props: any) => {
63+
captured.objectForm = props;
64+
return <div data-testid="object-form-double" />;
65+
});
66+
}, 30000);
67+
68+
afterAll(() => {
69+
ComponentRegistry.unregister('list-view');
70+
ComponentRegistry.unregister('object-form');
71+
});
72+
73+
beforeEach(() => {
74+
captured.listView = undefined;
75+
captured.objectForm = undefined;
76+
});
77+
78+
// ---------------------------------------------------------------------------
79+
// 1. Eligibility — the silent kill switch
80+
// ---------------------------------------------------------------------------
81+
82+
describe('kind:\'react\' scope eligibility', () => {
83+
it.each(['list-view', 'object-form'])(
84+
'`%s` is in the public contract and is not a container, so it is injected',
85+
(tag) => {
86+
const cfg = ComponentRegistry.getPublicConfigs().find((c: any) => c.type === tag);
87+
// Missing from PUBLIC_BLOCKS, or marked isContainer, => dropped from the
88+
// scope of every kind:'react' page (react-page.tsx:56-58).
89+
expect(cfg).toBeTruthy();
90+
expect((cfg as any).isContainer).toBeFalsy();
91+
},
92+
);
93+
});
94+
95+
// ---------------------------------------------------------------------------
96+
// 2. The contract authors rely on — no imports, flat props
97+
// ---------------------------------------------------------------------------
98+
99+
describe('kind:\'react\' page source', () => {
100+
it('resolves <ListView> and <ObjectForm> without the page importing them', async () => {
101+
const source = `
102+
function Page() {
103+
return (
104+
<div>
105+
<ListView objectName="showcase_project" fields={['name', 'status']} navigation={{ mode: 'none' }} onRowClick={() => {}} />
106+
<ObjectForm objectName="showcase_project" mode="edit" recordId="1" />
107+
</div>
108+
);
109+
}`;
110+
const { findByTestId, queryByText } = renderReactPage(source);
111+
112+
expect(await findByTestId('list-view-double')).toBeTruthy();
113+
expect(await findByTestId('object-form-double')).toBeTruthy();
114+
// The failure mode this guards: `ReferenceError: ListView is not defined`
115+
// surfaced through ReactRunner's fallback (react-page.tsx:170-175).
116+
expect(queryByText('React page error')).toBeNull();
117+
});
118+
119+
it('folds flat props into the block schema and preserves function props', async () => {
120+
const source = `
121+
function Page() {
122+
return <ListView objectName="showcase_project" fields={['name', 'status']} navigation={{ mode: 'none' }} onRowClick={() => {}} />;
123+
}`;
124+
const { findByTestId } = renderReactPage(source);
125+
await findByTestId('list-view-double');
126+
127+
// Flat JSX props are folded into the schema bag, and the discriminator wins
128+
// the `type` slot (react-page.tsx:71-76).
129+
expect(captured.listView.schema).toMatchObject({
130+
type: 'list-view',
131+
objectName: 'showcase_project',
132+
fields: ['name', 'status'],
133+
navigation: { mode: 'none' },
134+
});
135+
// Callbacks must survive as real props — the master/detail pattern in
136+
// apps/console/src/sdui-workbench-preview.tsx is built on onRowClick.
137+
expect(typeof captured.listView.onRowClick).toBe('function');
138+
// The wrapper stamps the live adapter in, since list-view reads dataSource
139+
// from props rather than from context (react-page.tsx:52-55).
140+
expect(captured.listView.schema.dataSource).toBe(adapter);
141+
});
142+
143+
it('injects useAdapter so a page can query on its own', async () => {
144+
const source = `
145+
function Page() {
146+
const a = useAdapter();
147+
return <div data-testid="adapter-probe">{a ? 'has-adapter' : 'no-adapter'}</div>;
148+
}`;
149+
const { findByTestId } = renderReactPage(source);
150+
expect((await findByTestId('adapter-probe')).textContent).toBe('has-adapter');
151+
});
152+
});
153+
154+
// ---------------------------------------------------------------------------
155+
// 3. Negative control — absence must be loud
156+
// ---------------------------------------------------------------------------
157+
158+
describe('kind:\'react\' unknown identifier', () => {
159+
it('surfaces the ReferenceError instead of failing silently', async () => {
160+
const source = `
161+
function Page() {
162+
return <TotallyNotARegisteredBlock />;
163+
}`;
164+
const { container } = renderReactPage(source);
165+
166+
// Proves the assertions above are meaningful: when a block really is absent
167+
// from scope, the author sees this.
168+
//
169+
// Asserted on the message rather than on a specific panel, because the
170+
// catching boundary is NOT the obvious one. ReactRunner is its own error
171+
// boundary, but `getDerivedStateFromProps` re-transpiles on every render
172+
// and unconditionally resets `error: null`, so the recovery render
173+
// re-throws and the error escapes PAST the "React page error" fallback to
174+
// SchemaRenderer's boundary ("Component "home" failed to render"). Pinning
175+
// the panel would pin that quirk; pinning the message pins the contract.
176+
await waitFor(() =>
177+
expect(container.textContent).toContain('TotallyNotARegisteredBlock is not defined'),
178+
);
179+
});
180+
});

0 commit comments

Comments
 (0)