|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * #24491 — the PAGE write door runs the visibility-predicate family. |
| 5 | + * |
| 6 | + * ## The state this closes |
| 7 | + * |
| 8 | + * `validateVisibilityPredicates` and `validatePredicatePathRefs` (the #7220 |
| 9 | + * family) declared `runtimeTypes: ['view']`, so `runtimeAuthoringRulesFor('page')` |
| 10 | + * left them out. A page component `visibleWhen` calling a function the CEL |
| 11 | + * environment does not register (`totallyBogusFn(1)`) was refused by |
| 12 | + * `os validate`, `os build` and `os lint`, and saved clean through Studio, |
| 13 | + * REST `/meta/page` and MCP. At render the predicate faults, the visibility |
| 14 | + * fallback is `true`, and the component renders unconditionally. |
| 15 | + * |
| 16 | + * ## The crossing |
| 17 | + * |
| 18 | + * Ruling of record 6161173492 on #23388 (letter B) moved the two page component |
| 19 | + * rules to this door on one principle: a page with a finding is fixed, then |
| 20 | + * saved. The family follows it, so both entries declare `['view', 'page']`. |
| 21 | + * No gate change: `page` already maps to `pages`, and the page snapshot holds |
| 22 | + * the item as `pages: [item]`. |
| 23 | + * |
| 24 | + * ## What the path half does on a page |
| 25 | + * |
| 26 | + * Nothing, at either door. `validatePredicatePathRefs` walks `stack.views` |
| 27 | + * only, because its oracle is a schema-bound form's `schemaId`, which a page |
| 28 | + * component does not declare. On a page, a wrong reference is the visibility |
| 29 | + * rule's verdict: a bare identifier is refused (`visibility-bare-identifier`), |
| 30 | + * and a `data.`-rooted path draws the advisory `visibility-root-mislayered`. |
| 31 | + * Both doors agree on it. The pin below says so, so that the inert half reads |
| 32 | + * as measured and not as coverage. |
| 33 | + * |
| 34 | + * The protocol-level half (the same verdict through the real `saveMetaItem` |
| 35 | + * and `publishMetaItem`, and a stored page that keeps serving until its next |
| 36 | + * save) is the #24491 block of |
| 37 | + * `packages/metadata-protocol/src/protocol.runtime-authoring-gate.test.ts`. |
| 38 | + */ |
| 39 | +import { describe, expect, it } from 'vitest'; |
| 40 | +import { runAuthoringRules, type AuthoringFinding } from './authoring-rules.js'; |
| 41 | +import { runRuntimeAuthoringRules, runtimeAuthoringRulesFor } from './runtime-gate.js'; |
| 42 | +import { |
| 43 | + VISIBILITY_BARE_IDENTIFIER, |
| 44 | + VISIBILITY_PREDICATE_OVER_BUDGET, |
| 45 | + VISIBILITY_PREDICATE_SYNTAX, |
| 46 | + VISIBILITY_PREDICATE_UNKNOWN_FUNCTION, |
| 47 | + VISIBILITY_ROOT_MISLAYERED, |
| 48 | +} from './validate-visibility-predicates.js'; |
| 49 | +import { |
| 50 | + PREDICATE_PATH_UNRESOLVED, |
| 51 | + PREDICATE_PATH_UNROOTED, |
| 52 | + PREDICATE_RHS_PATH_SHAPED, |
| 53 | +} from './validate-predicate-path-refs.js'; |
| 54 | + |
| 55 | +const FAMILY = ['validateVisibilityPredicates', 'validatePredicatePathRefs']; |
| 56 | + |
| 57 | +/** Every rule id the two family entries emit; the page door also runs the component rules. */ |
| 58 | +const FAMILY_IDS = new Set<string>([ |
| 59 | + VISIBILITY_BARE_IDENTIFIER, |
| 60 | + VISIBILITY_PREDICATE_OVER_BUDGET, |
| 61 | + VISIBILITY_PREDICATE_SYNTAX, |
| 62 | + VISIBILITY_PREDICATE_UNKNOWN_FUNCTION, |
| 63 | + VISIBILITY_ROOT_MISLAYERED, |
| 64 | + PREDICATE_PATH_UNRESOLVED, |
| 65 | + PREDICATE_PATH_UNROOTED, |
| 66 | + PREDICATE_RHS_PATH_SHAPED, |
| 67 | +]); |
| 68 | + |
| 69 | +/** One `page:header` whose props are declared, so a finding is the predicate's. */ |
| 70 | +const page = (visibleWhen: unknown) => ({ |
| 71 | + name: 'pipeline_home', |
| 72 | + label: 'Pipeline', |
| 73 | + type: 'app', |
| 74 | + regions: [ |
| 75 | + { name: 'main', components: [{ type: 'page:header', properties: { title: 'Pipeline' }, visibleWhen }] }, |
| 76 | + ], |
| 77 | +}); |
| 78 | +const AT = 'pages[0].regions[0].components[0]'; |
| 79 | +const WHERE = 'page "pipeline_home"'; |
| 80 | + |
| 81 | +/** |
| 82 | + * One refused body per finding kind, each refused by `os build` at `error`. |
| 83 | + * The first is the card's shape; the second is the `{ dialect, source }` |
| 84 | + * envelope the platform's own pages author through `P`. |
| 85 | + */ |
| 86 | +const REFUSED = [ |
| 87 | + { body: 'totallyBogusFn(1)', rule: VISIBILITY_PREDICATE_UNKNOWN_FUNCTION, subject: '`totallyBogusFn`' }, |
| 88 | + { body: { dialect: 'cel', source: 'totallyBogusFn(1)' }, rule: VISIBILITY_PREDICATE_UNKNOWN_FUNCTION, subject: '`totallyBogusFn`' }, |
| 89 | + { body: "current_user.canApprove(record, 'approve')", rule: VISIBILITY_PREDICATE_UNKNOWN_FUNCTION, subject: '`canApprove`' }, |
| 90 | + // A wrong reference: no binding root resolves `status`. |
| 91 | + { body: "status == 'open'", rule: VISIBILITY_BARE_IDENTIFIER, subject: '`status`' }, |
| 92 | + { body: 'record.status ==', rule: VISIBILITY_PREDICATE_SYNTAX, subject: 'not valid CEL' }, |
| 93 | +] as const; |
| 94 | + |
| 95 | +/** |
| 96 | + * Bodies the build accepts on a page component, and so must the door: the |
| 97 | + * shapes the platform and the examples ship (`has()`-guarded record reads, a |
| 98 | + * page variable, the acting user, the `P` envelope) and registered functions. |
| 99 | + */ |
| 100 | +const ACCEPTED = [ |
| 101 | + "has(record.status) && record.status == 'in_review'", |
| 102 | + { dialect: 'cel', source: 'has(record.id) && has(record.email_verified) && record.email_verified == false' }, |
| 103 | + "page.selectedProjectId != ''", |
| 104 | + "current_user.email == 'admin@objectos.ai'", |
| 105 | + 'size(record.name) > 0', |
| 106 | + "upper(record.name) == 'X'", |
| 107 | +] as const; |
| 108 | + |
| 109 | +const gatePage = (item: unknown) => |
| 110 | + runRuntimeAuthoringRules({ type: 'page', item, context: { objects: [] } }); |
| 111 | + |
| 112 | +const family = <T extends { rule: string }>(fs: readonly T[]): T[] => fs.filter((f) => FAMILY_IDS.has(f.rule)); |
| 113 | + |
| 114 | +/** The build's family findings for one page, through the build's own entry. */ |
| 115 | +const buildFindings = (item: unknown): AuthoringFinding[] => { |
| 116 | + const stack = { pages: [item] }; |
| 117 | + return family(runAuthoringRules('build', { normalized: stack, parsed: stack })); |
| 118 | +}; |
| 119 | + |
| 120 | +const dump = (r: unknown) => JSON.stringify(r, null, 2); |
| 121 | + |
| 122 | +describe('#24491 — the page door gives the build\'s visibility-predicate verdict', () => { |
| 123 | + it('dispatches `page` writes to both family rules, after the component rules', () => { |
| 124 | + expect(runtimeAuthoringRulesFor('page').map((r) => r.name)).toEqual([ |
| 125 | + 'validatePresetComparands', |
| 126 | + 'validateComponentProps', |
| 127 | + 'validateComponentTypes', |
| 128 | + 'validatePrintPageBlocks', |
| 129 | + ...FAMILY, |
| 130 | + ]); |
| 131 | + }); |
| 132 | + |
| 133 | + for (const refused of REFUSED) { |
| 134 | + const { body, rule, subject } = refused; |
| 135 | + it(`⭐ LIT — a component \`visibleWhen: ${JSON.stringify(body)}\` is REFUSED at the component`, () => { |
| 136 | + const result = gatePage(page(body)); |
| 137 | + |
| 138 | + expect(result.rulesRun).toEqual(expect.arrayContaining(FAMILY)); |
| 139 | + const errs = family(result.errors); |
| 140 | + expect(errs, dump(result)).toHaveLength(1); |
| 141 | + expect(errs[0]).toMatchObject({ rule, severity: 'error', where: WHERE, path: AT }); |
| 142 | + expect(errs[0]!.message).toContain(subject); |
| 143 | + }); |
| 144 | + } |
| 145 | + |
| 146 | + for (const body of ACCEPTED) { |
| 147 | + it(`⭐ CONTROL — a component \`visibleWhen: ${JSON.stringify(body)}\` publishes clean, and the build agrees`, () => { |
| 148 | + const result = gatePage(page(body)); |
| 149 | + |
| 150 | + expect(result.rulesRun).toEqual(expect.arrayContaining(FAMILY)); |
| 151 | + expect(family(result.errors), dump(result)).toEqual([]); |
| 152 | + expect(family(result.advisories), dump(result)).toEqual([]); |
| 153 | + expect(buildFindings(page(body))).toEqual([]); |
| 154 | + }); |
| 155 | + } |
| 156 | + |
| 157 | + it('⭐ PARITY — for each refused page body the door findings ARE the build findings', () => { |
| 158 | + for (const { body } of REFUSED) { |
| 159 | + const atBuild = buildFindings(page(body)); |
| 160 | + const atDoor = family(gatePage(page(body)).errors); |
| 161 | + |
| 162 | + // Non-vacuous: the build refuses each of them. |
| 163 | + expect(atBuild.filter((f) => f.severity === 'error'), JSON.stringify(body)).toHaveLength(1); |
| 164 | + // Rule, location, message and hint: the whole finding, compared as one. |
| 165 | + expect(atDoor, JSON.stringify(body)).toEqual(atBuild); |
| 166 | + } |
| 167 | + }); |
| 168 | + |
| 169 | + it('the path half judges nothing on a page: a `data.` path draws the same advisory at both doors, and no refusal', () => { |
| 170 | + const body = page("data.tpye == 'formula'"); |
| 171 | + const result = gatePage(body); |
| 172 | + |
| 173 | + expect(result.rulesRun).toContain('validatePredicatePathRefs'); |
| 174 | + expect(family(result.errors), dump(result)).toEqual([]); |
| 175 | + expect(family(result.advisories).map((f) => f.rule), dump(result)).toEqual([VISIBILITY_ROOT_MISLAYERED]); |
| 176 | + expect(family(result.advisories)).toEqual(buildFindings(body)); |
| 177 | + }); |
| 178 | + |
| 179 | + it('the `view` control: a view write is judged exactly as before', () => { |
| 180 | + expect(runtimeAuthoringRulesFor('view').map((r) => r.name)).toEqual([ |
| 181 | + 'validatePresetComparands', |
| 182 | + 'validateReferenceIntegrity', |
| 183 | + ...FAMILY, |
| 184 | + ]); |
| 185 | + const view = (visibleWhen: string, data?: unknown) => ({ |
| 186 | + name: 'field_editor', |
| 187 | + label: 'Field editor', |
| 188 | + form: { |
| 189 | + ...(data ? { data } : {}), |
| 190 | + sections: [{ label: 'Main', fields: [{ field: 'name', visibleWhen }] }], |
| 191 | + }, |
| 192 | + }); |
| 193 | + const gateView = (item: unknown) => runRuntimeAuthoringRules({ type: 'view', item, context: { objects: [] } }); |
| 194 | + |
| 195 | + const fn = family(gateView(view('totallyBogusFn(1)')).errors); |
| 196 | + expect(fn.map((f) => [f.rule, f.path])).toEqual([ |
| 197 | + [VISIBILITY_PREDICATE_UNKNOWN_FUNCTION, 'views[0].form.sections[0].fields[0]'], |
| 198 | + ]); |
| 199 | + // The path half is live on the view surface it was built for. |
| 200 | + const path = family(gateView(view("data.tpye == 'text'", { provider: 'schema', schemaId: 'field' })).errors); |
| 201 | + expect(path.map((f) => f.rule)).toContain(PREDICATE_PATH_UNRESOLVED); |
| 202 | + }); |
| 203 | +}); |
0 commit comments