|
| 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 | + |
| 9 | +/** |
| 10 | + * Page / App / Dashboard ↔ `@objectstack/spec` drift guard (objectstack#4115). |
| 11 | + * |
| 12 | + * These three renderer nodes extend `BaseSchema`, which is `.passthrough()`, |
| 13 | + * while their spec counterparts are strict. That combination is the objectui |
| 14 | + * equivalent of the objectstack#4120 silent-drop failure: every spec-only key |
| 15 | + * — `branding`, `sharing`, `interfaceConfig`, `source`, `header`, |
| 16 | + * `performance`, the whole `_lock*` package envelope — rode through |
| 17 | + * *completely unvalidated*, so `brading: {…}` was indistinguishable from |
| 18 | + * `branding: {…}` and a spec-authored `kind: 'html'` page could not even |
| 19 | + * express its body (`source` was never declared). |
| 20 | + * |
| 21 | + * The fix is NOT `.strict()` — these are component nodes and the open envelope |
| 22 | + * is deliberate. It is to let the spec's own fields flow in by reference |
| 23 | + * (`SpecXFields = SpecXSchema.omit({…}).partial()`), the pattern |
| 24 | + * `zod/objectql.zod.ts`'s `ListViewSchema` already uses. |
| 25 | + * |
| 26 | + * The derivation picks up new spec fields automatically, but can still break |
| 27 | + * silently in three ways, which these tests catch: |
| 28 | + * |
| 29 | + * 1. The spec grows a field objectui never triaged. Asserted: every spec key |
| 30 | + * is present in the objectui shape, or envelope-owned by BaseSchema. |
| 31 | + * 2. The spec renames/removes a field objectui deliberately omits and |
| 32 | + * re-declares locally — the local override would then shadow nothing. |
| 33 | + * Asserted: every omitted anchor still exists upstream. |
| 34 | + * 3. Someone adds an objectui-local key that should have been a spec field. |
| 35 | + * Asserted: every objectui-only key is in the sanctioned set below. |
| 36 | + * |
| 37 | + * When one fails, do NOT edit the sets to make it green — decide whether the |
| 38 | + * field belongs upstream in `@objectstack/spec` or is a genuine objectui-only |
| 39 | + * extension, and record the reason. |
| 40 | + */ |
| 41 | + |
| 42 | +import { describe, it, expect } from 'vitest'; |
| 43 | +import { |
| 44 | + AppSchema as SpecAppSchema, |
| 45 | + DashboardSchema as SpecDashboardSchema, |
| 46 | + PageSchema as SpecPageSchema, |
| 47 | +} from '@objectstack/spec/ui'; |
| 48 | +import { AppSchema as OuiAppSchema } from '../zod/app.zod.js'; |
| 49 | +import { DashboardSchema as OuiDashboardSchema } from '../zod/complex.zod.js'; |
| 50 | +import { PageSchema as OuiPageSchema } from '../zod/layout.zod.js'; |
| 51 | +import { BaseSchema } from '../zod/base.zod.js'; |
| 52 | + |
| 53 | +const shapeOf = (s: unknown) => (s as { shape: Record<string, unknown> }).shape; |
| 54 | + |
| 55 | +/** Component-envelope keys owned by BaseSchema — derived, never hand-listed. */ |
| 56 | +const ENVELOPE = new Set(Object.keys(shapeOf(BaseSchema))); |
| 57 | + |
| 58 | +interface Case { |
| 59 | + spec: unknown; |
| 60 | + oui: unknown; |
| 61 | + /** Spec keys objectui deliberately omits and re-declares locally. */ |
| 62 | + omitted: string[]; |
| 63 | + /** objectui-only keys, each a conscious local-vs-upstream decision. */ |
| 64 | + local: string[]; |
| 65 | +} |
| 66 | + |
| 67 | +const CASES: Record<string, Case> = { |
| 68 | + Page: { |
| 69 | + spec: SpecPageSchema, |
| 70 | + oui: OuiPageSchema, |
| 71 | + // `type` collides semantically (spec = page kind, objectui = component |
| 72 | + // discriminator, kind lives on `pageType`); `regions` is a local fork. |
| 73 | + omitted: ['type', 'regions'], |
| 74 | + local: ['title', 'pageType', 'body', 'children'], |
| 75 | + }, |
| 76 | + App: { |
| 77 | + spec: SpecAppSchema, |
| 78 | + oui: OuiAppSchema, |
| 79 | + omitted: ['navigation', 'areas', 'contextSelectors'], |
| 80 | + local: ['title', 'logo', 'favicon', 'layout', 'menu', 'actions'], |
| 81 | + }, |
| 82 | + Dashboard: { |
| 83 | + spec: SpecDashboardSchema, |
| 84 | + oui: OuiDashboardSchema, |
| 85 | + omitted: ['widgets', 'globalFilters', 'dateRange'], |
| 86 | + local: [], |
| 87 | + }, |
| 88 | +}; |
| 89 | + |
| 90 | +describe.each(Object.keys(CASES))('%s spec parity (objectstack#4115 drift guard)', (name) => { |
| 91 | + const { spec, oui, omitted, local } = CASES[name]; |
| 92 | + const specShape = shapeOf(spec); |
| 93 | + const ouiShape = shapeOf(oui); |
| 94 | + const ouiKeys = new Set(Object.keys(ouiShape)); |
| 95 | + |
| 96 | + it('covers every spec field — the spec cannot grow a key objectui ignores', () => { |
| 97 | + const missing = Object.keys(specShape).filter((k) => !ouiKeys.has(k) && !ENVELOPE.has(k)); |
| 98 | + expect(missing).toEqual([]); |
| 99 | + }); |
| 100 | + |
| 101 | + it('keeps the upstream anchors it deliberately omits and re-declares', () => { |
| 102 | + // A rename upstream would orphan the local override, silently reopening |
| 103 | + // the very drift this derivation closed. |
| 104 | + for (const key of omitted) { |
| 105 | + expect(specShape, `spec no longer declares '${key}'`).toHaveProperty(key); |
| 106 | + } |
| 107 | + }); |
| 108 | + |
| 109 | + it('declares no objectui-only key outside the sanctioned set', () => { |
| 110 | + const rogue = [...ouiKeys].filter( |
| 111 | + (k) => !(k in specShape) && !ENVELOPE.has(k) && !local.includes(k), |
| 112 | + ); |
| 113 | + expect(rogue).toEqual([]); |
| 114 | + }); |
| 115 | +}); |
| 116 | + |
| 117 | +describe('spec-only keys are now VALIDATED, not passed through (objectstack#4115)', () => { |
| 118 | + it('Page declares the five keys that used to ride through unchecked', () => { |
| 119 | + const keys = Object.keys(shapeOf(OuiPageSchema)); |
| 120 | + for (const key of ['interfaceConfig', 'kind', 'slots', 'source', 'requires']) { |
| 121 | + expect(keys, `'${key}' is still undeclared`).toContain(key); |
| 122 | + } |
| 123 | + }); |
| 124 | + |
| 125 | + it('Page can finally express a source-authored page (kind html/react)', () => { |
| 126 | + // Before the derivation `source` was not declared at all, so `kind: 'html'` |
| 127 | + // was unauthorable — the body had nowhere to live. |
| 128 | + const ok = OuiPageSchema.safeParse({ type: 'page', kind: 'html', source: '<h1>hi</h1>' }); |
| 129 | + expect(ok.success).toBe(true); |
| 130 | + expect(ok.success && (ok.data as { source?: string }).source).toBe('<h1>hi</h1>'); |
| 131 | + }); |
| 132 | + |
| 133 | + it('Page rejects a bad `kind` instead of waving it through', () => { |
| 134 | + expect(OuiPageSchema.safeParse({ type: 'page', kind: 'nonsense' }).success).toBe(false); |
| 135 | + }); |
| 136 | + |
| 137 | + it('App declares the package-lock and access keys that used to ride through', () => { |
| 138 | + const keys = Object.keys(shapeOf(OuiAppSchema)); |
| 139 | + for (const key of ['branding', 'sharing', 'embed', 'objects', 'apis', 'requiredPermissions', 'homePageId', '_packageId']) { |
| 140 | + expect(keys, `'${key}' is still undeclared`).toContain(key); |
| 141 | + } |
| 142 | + }); |
| 143 | + |
| 144 | + it('App validates branding instead of accepting any shape', () => { |
| 145 | + expect(OuiAppSchema.safeParse({ type: 'app', branding: { primaryColor: '#fff' } }).success).toBe(true); |
| 146 | + // A wrong-typed branding used to be indistinguishable from a correct one. |
| 147 | + expect(OuiAppSchema.safeParse({ type: 'app', branding: 'blue' }).success).toBe(false); |
| 148 | + }); |
| 149 | + |
| 150 | + it('Dashboard declares header/refreshInterval/performance', () => { |
| 151 | + const keys = Object.keys(shapeOf(OuiDashboardSchema)); |
| 152 | + for (const key of ['header', 'refreshInterval', 'performance', 'protection']) { |
| 153 | + expect(keys, `'${key}' is still undeclared`).toContain(key); |
| 154 | + } |
| 155 | + }); |
| 156 | + |
| 157 | + it('Dashboard validates header instead of accepting any shape', () => { |
| 158 | + expect( |
| 159 | + OuiDashboardSchema.safeParse({ type: 'dashboard', widgets: [], header: { showTitle: true } }).success, |
| 160 | + ).toBe(true); |
| 161 | + expect( |
| 162 | + OuiDashboardSchema.safeParse({ type: 'dashboard', widgets: [], header: 'yes' }).success, |
| 163 | + ).toBe(false); |
| 164 | + }); |
| 165 | +}); |
| 166 | + |
| 167 | +describe('the derivation stays permissive where objectui needs it', () => { |
| 168 | + it('no spec field became required — stored payloads keep validating', () => { |
| 169 | + // `.partial()` on the imported shape is what guarantees this; without it a |
| 170 | + // spec field gaining `required` would invalidate every stored node. |
| 171 | + expect(OuiPageSchema.safeParse({ type: 'page' }).success).toBe(true); |
| 172 | + expect(OuiAppSchema.safeParse({ type: 'app' }).success).toBe(true); |
| 173 | + expect(OuiDashboardSchema.safeParse({ type: 'dashboard', widgets: [] }).success).toBe(true); |
| 174 | + }); |
| 175 | + |
| 176 | + it('the local vocabulary still parses', () => { |
| 177 | + expect( |
| 178 | + OuiPageSchema.safeParse({ type: 'page', pageType: 'record', title: 'T', regions: [] }).success, |
| 179 | + ).toBe(true); |
| 180 | + expect( |
| 181 | + OuiAppSchema.safeParse({ type: 'app', title: 'T', logo: '/l.png', layout: 'sidebar' }).success, |
| 182 | + ).toBe(true); |
| 183 | + }); |
| 184 | + |
| 185 | + it('the component envelope still passes unknown renderer props through', () => { |
| 186 | + // BaseSchema is deliberately open for type-specific extensions; the fix |
| 187 | + // narrows what the SPEC owns, it does not close the node. |
| 188 | + const r = OuiPageSchema.safeParse({ type: 'page', someRendererProp: 42 }); |
| 189 | + expect(r.success).toBe(true); |
| 190 | + }); |
| 191 | +}); |
0 commit comments