Repository navigation
Expand file tree
/
Copy pathruntime-gate.view-writes.test.ts
More file actions
423 lines (385 loc) · 20.7 KB
/
Copy pathruntime-gate.view-writes.test.ts
File metadata and controls
423 lines (385 loc) · 20.7 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
//
// #9313 — the two list-view FIELD rules at the runtime publish gate, on the
// flattened standalone list overlay. #10001 extends the same door onto the
// standalone ViewItem RECORD (`config.sort` / `config.searchableFields`, one
// level down) — the boundary marker #9313 left here is flipped in the record
// block below.
//
// The card's own trap, restated because every test here exists to spring it:
// widening the reference-integrity suite's `runtimeTypes` to `view` is
// necessary and NOT sufficient. The rules' metadata walk read
// `objects[].listViews.*` and `views[].list` / `views[].listViews.*` — never a
// TOP-LEVEL flattened overlay, which is precisely the shape a standalone list
// view takes through `PUT /api/v1/meta/view` (the only door a Studio tenant or
// an MCP/AI author has) and the shape the gate snapshots as `views: [item]`.
// A dispatch-only widening is therefore a silent no-op that reads as coverage,
// and the refusal tests below are the ones that distinguish the two: they FAIL
// with the widening alone and pass only once the walk's self rung exists.
//
// Like `runtime-gate.test.ts`'s #7220 block, everything drives the REAL
// dispatch path — `runRuntimeAuthoringRules`, the function
// `packages/metadata-protocol/src/runtime-authoring-gate.ts` calls — with
// bodies shaped exactly as `saveMetaItem` stores them (identity stamped by
// `normalizeViewMetadata`, console `sort[].id` decorations intact: the store
// persists the ORIGINAL body, so the gate judges them too).
import { describe, it, expect } from 'vitest';
import { runRuntimeAuthoringRules } from './runtime-gate.js';
import { REFERENCE_INTEGRITY_RULES } from './reference-integrity-suite.js';
import { SORT_FIELD_UNKNOWN, SORT_FIELD_UNSORTABLE } from './validate-sortable-fields.js';
import { SEARCHABLE_FIELD_UNKNOWN } from './validate-searchable-fields.js';
import { LIST_VIEW_FIELD_UNKNOWN } from './validate-list-view-field-refs.js';
/** The live object universe the gate resolves against (`RuntimeStackContext.objects`). */
const objects = [
{
name: 'crm_case',
label: 'Case',
fields: {
name: { type: 'text', label: 'Name' },
status: { type: 'select', label: 'Status', options: ['open', 'closed'] },
// A REAL field with no stored column — existence passes, virtuality fails.
days_open: { type: 'formula', label: 'Days Open' },
},
},
];
/**
* A flattened standalone list overlay, as the wire carries it: a raw ListView
* config at the TOP level, `object` + `viewKind` required (#7741), plus the
* console decorations a personalization PUT carries — `sort[].id` is
* objectui's `crypto.randomUUID()` row id (#5074). It was stored verbatim
* until #20051 made a view store its parsed body; rows stored before that
* still carry it, so the rules keep being judged on it.
*/
const overlay = (patch: Record<string, unknown>) => ({
name: 'crm_case.custom',
object: 'crm_case',
viewKind: 'list',
type: 'grid',
columns: ['name', 'status'],
...patch,
});
const gate = (item: unknown) => runRuntimeAuthoringRules({ type: 'view', item, context: { objects } });
describe('a flattened list overlay at the runtime publish gate (#9313)', () => {
// ── the refusals the card exists for — red without the walk's self rung ──
it('REFUSES a top-level `sort` naming an unknown field', () => {
const { errors } = gate(overlay({
sort: [{ id: 'a2b4c86e-1111-4111-8111-000000000001', field: 'amout', order: 'desc' }],
}));
const f = errors.find((e) => e.rule === SORT_FIELD_UNKNOWN);
expect(f, JSON.stringify(errors)).toBeDefined();
expect(f!.path).toBe('views[0].sort[0]');
expect(f!.where).toContain('flattened list overlay');
});
it('REFUSES a top-level `sort` naming a formula field — no column to ORDER BY', () => {
const { errors } = gate(overlay({ sort: [{ field: 'days_open', order: 'desc' }] }));
const f = errors.find((e) => e.rule === SORT_FIELD_UNSORTABLE);
expect(f, JSON.stringify(errors)).toBeDefined();
expect(f!.path).toBe('views[0].sort[0]');
});
it('REFUSES a top-level `searchableFields` entry that resolves to no field', () => {
const { errors } = gate(overlay({ searchableFields: ['name', 'budget'] }));
const f = errors.find((e) => e.rule === SEARCHABLE_FIELD_UNKNOWN);
expect(f, JSON.stringify(errors)).toBeDefined();
expect(f!.path).toBe('views[0].searchableFields[1]');
});
it('honors the overlay\'s own `data.object` binding over its top-level `object`', () => {
// ADR-0047's explicit retarget, the same resolution order every other
// list-view rung reads: `days_open` is real on crm_case, so a finding here
// proves the rung resolved against the RETARGETED object, not the identity
// binding.
const { errors } = gate(overlay({
object: 'crm_other',
data: { provider: 'object', object: 'crm_case' },
sort: 'nonexistent_field desc',
}));
expect(errors.map((e) => e.rule)).toContain(SORT_FIELD_UNKNOWN);
});
// ── what must keep publishing ──
it('a console-shaped personalization PUT publishes clean — UUID row ids and all', () => {
const result = gate(overlay({
sort: [{ id: 'a2b4c86e-1111-4111-8111-000000000002', field: 'status', order: 'asc' }],
searchableFields: ['name'],
isPinned: true,
sortOrder: 3,
}));
expect(result.errors, JSON.stringify(result.errors)).toEqual([]);
// "clean" and "nothing ran" must stay distinguishable.
expect(result.rulesRun).toContain('validateReferenceIntegrity');
});
it('a system-column sort publishes clean — the platform\'s own most common ordering', () => {
const { errors } = gate(overlay({ sort: [{ field: 'created_at', order: 'desc' }] }));
expect(errors, JSON.stringify(errors)).toEqual([]);
});
it('an overlay bound to an object outside the live universe: its field positions are not judged, its binding is', () => {
// Skip ① for the field axes — one typo is one finding, never one per
// position. [#24668] The binding itself is refused once: with no package
// named, the live universe is the whole collection, so an object missing
// from it exists nowhere (`runtime-gate.whole-collection.test.ts`).
const { errors } = gate(overlay({ object: 'pkg_external_case', sort: 'no_such_field' }));
expect(errors.map((e) => [e.rule, e.path]), JSON.stringify(errors)).toEqual([
['list-view-object-unknown', 'views[0].object'],
]);
});
// ── [#14107] the rest of the same overlay's field surface ──
//
// The two rules above own `sort` and `searchableFields`; every OTHER
// field-naming position on the same overlay was resolved by nothing, at this
// door as well as at the CLI. Same self rung, same skips, same binding
// order — these are the refusal/clean pair that distinguishes a real
// crossing from a dispatch-only no-op.
it('REFUSES a top-level `columns` entry that resolves to no field', () => {
const { errors } = gate(overlay({ columns: ['name', 'budgett'] }));
const f = errors.find((e) => e.rule === LIST_VIEW_FIELD_UNKNOWN);
expect(f, JSON.stringify(errors)).toBeDefined();
expect(f!.path).toBe('views[0].columns[1]');
expect(f!.where).toContain('flattened list overlay');
});
it('REFUSES a top-level `kanban.groupByField` that resolves to no field', () => {
const { errors } = gate(overlay({ kanban: { groupByField: 'statuss', columns: ['name'] } }));
const f = errors.find((e) => e.rule === LIST_VIEW_FIELD_UNKNOWN);
expect(f, JSON.stringify(errors)).toBeDefined();
expect(f!.path).toBe('views[0].kanban.groupByField');
expect(f!.message).toContain('Did you mean "status"?');
});
it('REFUSES a top-level `filter` key that resolves to no field', () => {
const { errors } = gate(overlay({
filter: [{ field: 'budget', operator: 'equals', value: 1 }],
}));
const f = errors.find((e) => e.rule === LIST_VIEW_FIELD_UNKNOWN);
expect(f, JSON.stringify(errors)).toBeDefined();
expect(f!.path).toBe('views[0].filter[0].field');
});
it('a fully bound overlay publishes clean across every one of those positions', () => {
const result = gate(overlay({
columns: ['name', { field: 'status' }],
filter: [{ field: 'status', operator: 'equals', value: 'open' }],
grouping: { fields: [{ field: 'status' }] },
rowColor: { field: 'status' },
kanban: { groupByField: 'status', columns: ['name'] },
hiddenFields: ['days_open'],
}));
expect(result.errors, JSON.stringify(result.errors)).toEqual([]);
expect(result.rulesRun).toContain('validateReferenceIntegrity');
});
it('a system column in a walked position publishes clean (skip ③)', () => {
const { errors } = gate(overlay({ columns: ['name', 'created_at'] }));
expect(errors, JSON.stringify(errors)).toEqual([]);
});
// ── the granularity wall: exactly two members cross, nothing rides along ──
it('does NOT refuse an overlay for a rowAction naming a stack-level action — no member rides along', () => {
// A regression pin on the FLATTENED shape, not the granularity
// discriminator: `validateActionNameRefs` walks `views[].list` /
// `views[].listViews.*` and has no flattened-overlay rung, so on THIS body
// it stays silent even if its member declaration crossed onto `view`
// (measured by ablation — crossing it left this test green). The
// behavioural discriminator is the CONTAINER control below; the
// declaration itself is pinned by the member-surface test.
const result = gate(overlay({ rowActions: ['close_case'], bulkActions: ['mass_close'] }));
expect(result.errors, JSON.stringify(result.errors)).toEqual([]);
// The suite RAN — the zero above is a dispatch decision, not a dead gate.
expect(result.rulesRun).toContain('validateReferenceIntegrity');
});
it('does NOT refuse a CONTAINER view write naming a stack-level action — the phantom channel stays walled', () => {
// THE behavioural control for the granularity decision, on the shape
// where the channel is real. A container's `list.rowActions` IS walked by
// `validateActionNameRefs` (`views[].list` rung), and the per-write
// snapshot carries no `stack.actions` — so if the suite's widening let
// that member ride onto `view` writes, this legitimate body would be
// refused with `action-name-undefined` for every stack-level action it
// names (measured: 1 finding on the snapshot shape, 0 with the full
// stack). The member wall is what keeps this green.
const container = {
list: {
type: 'grid',
data: { provider: 'object', object: 'crm_case' },
columns: ['name', 'status'],
rowActions: ['stack_level_close_case'],
bulkActions: ['stack_level_mass_close'],
},
};
const result = gate(container);
expect(result.errors, JSON.stringify(result.errors)).toEqual([]);
expect(result.rulesRun).toContain('validateReferenceIntegrity');
});
it('pins the member surface: exactly the crossed members declare `view`', () => {
const crossed = REFERENCE_INTEGRITY_RULES
.filter((r) => (r.runtimeTypes ?? ['flow']).includes('view'))
.map((r) => r.name);
// [#13216 / #17063] `validateViewPageRefs` was the third crossing, and the
// only one that was not a field-existence question: a `type: 'page'` view's
// `pageName`, resolved against `stack.pages` — a collection the per-write
// snapshot gained in the same change. It left this list with the view mount
// it resolved, and `RuntimeStackContext.pages` left with it, so no crossing
// reaches for a collection the snapshot does not carry. The list is written
// out, not derived, precisely so a change either way has to be argued here.
// [#14107] The fourth crossing, argued here as this list demands. It is the
// same KIND of crossing as the first two — a list view's field references,
// resolved against `stack.objects`, the one collection every per-write
// snapshot carries — so it has no missing-collection false-positive
// channel to open; the controls directly below are its measurement, on the
// shape the door actually carries. Not crossing it would have been the
// #9313 failure inverted: the standalone list view a Studio tenant or an
// MCP/AI author writes goes through `PUT /api/v1/meta/view` and no CLI, so
// a build-time-only rule never reaches the author who made the typo.
expect(crossed).toEqual([
'validateSearchableFields',
'validateSortableFields',
'validateListViewFieldRefs',
]);
// And every member still judges flow snapshots — the #4463 P1 surface is
// not narrowed by the member axis existing.
const offFlow = REFERENCE_INTEGRITY_RULES
.filter((r) => !(r.runtimeTypes ?? ['flow']).includes('flow'))
.map((r) => r.name);
expect(offFlow).toEqual([]);
});
// ── [#10001] the ViewItem RECORD rung — the #9313 boundary marker, flipped ──
//
// The test that stood here pinned the opposite: "a ViewItem RECORD's nested
// `config.sort` is not judged here — recorded scope, not a rung that fell
// off". That marker kept the gap RECORDED until its own card; #10001 is that
// card, and this block is the rung's proof. The record is
// `ViewMetadataSchema`'s member 1 (`ViewItemWireSchema`,
// `{ name, object, viewKind: 'list', config }`) — the shape a Studio-saved
// view takes through `PUT /api/v1/meta/view`, and a hot one: objectui's
// `updateView` GETs the stored record and PUTs `{ ...current, ...partial }`
// (`view.zod.ts`'s #5074 trace), so every pin/reorder toggle round-trips the
// whole record, `config` included.
/** A ViewItem record as the console PUTs it back (the stored record plus the patch). */
const record = (
configPatch: Record<string, unknown>,
patch: Record<string, unknown> = {},
) => ({
name: 'crm_case.pipeline',
object: 'crm_case',
viewKind: 'list',
config: { type: 'grid', columns: ['name'], ...configPatch },
...patch,
});
it('REFUSES a record\'s `config.sort` naming an unknown field — the flipped boundary marker (#10001)', () => {
const { errors } = gate(record({ sort: [{ field: 'amout', order: 'desc' }] }));
const f = errors.find((e) => e.rule === SORT_FIELD_UNKNOWN);
expect(f, JSON.stringify(errors)).toBeDefined();
expect(f!.path).toBe('views[0].config.sort[0]');
expect(f!.where).toContain('ViewItem record');
});
it('REFUSES a record\'s `config.sort` naming a formula field — no column to ORDER BY (#10001)', () => {
const { errors } = gate(record({ sort: [{ field: 'days_open', order: 'desc' }] }));
const f = errors.find((e) => e.rule === SORT_FIELD_UNSORTABLE);
expect(f, JSON.stringify(errors)).toBeDefined();
expect(f!.path).toBe('views[0].config.sort[0]');
});
it('REFUSES a record\'s `config.searchableFields` entry that resolves to no field (#10001)', () => {
const { errors } = gate(record({ searchableFields: ['name', 'budget'] }));
const f = errors.find((e) => e.rule === SEARCHABLE_FIELD_UNKNOWN);
expect(f, JSON.stringify(errors)).toBeDefined();
expect(f!.path).toBe('views[0].config.searchableFields[1]');
});
it('honors the record config\'s own `data.object` binding over the record\'s `object` (#10001)', () => {
// ADR-0047's explicit retarget, resolved on the CONFIG (where a record's
// data binding lives), ahead of the record's top-level `object` — the
// same order every other list-view rung reads.
const { errors } = gate(record(
{ data: { provider: 'object', object: 'crm_case' }, sort: 'nonexistent_field desc' },
{ object: 'crm_other' },
));
expect(errors.map((e) => e.rule)).toContain(SORT_FIELD_UNKNOWN);
});
it('a console-shaped record round-trip publishes clean — `updateView`\'s merged PUT, decorations and all (#10001)', () => {
// `{ ...current, ...partial }`: `isPinned`/`sortOrder` at the top,
// `config.sort[].id` carrying objectui's `crypto.randomUUID()` row ids
// (#5074) — `saveMetaItem` persists the original body, so the gate judges
// exactly this shape.
const result = gate(record(
{
sort: [{ id: 'a2b4c86e-1111-4111-8111-000000000003', field: 'status', order: 'asc' }],
searchableFields: ['name'],
},
{ isPinned: true, sortOrder: 2 },
));
expect(result.errors, JSON.stringify(result.errors)).toEqual([]);
expect(result.rulesRun).toContain('validateReferenceIntegrity');
});
it('judges a record on its `config` rung ONLY — a stray top-level `sort` is not judged as an overlay (#10001)', () => {
// The rung-split control. The overlay rung's `!isRec(config)` guard keeps
// record bodies out, and the record rung reads `config` alone — so a
// record carrying a stray top-level `sort` (the wire schema strips the
// key, but `saveMetaItem` persists the ORIGINAL body) yields exactly one
// finding, on the config path. Two findings here = the rungs leaked into
// each other's shapes; a top-level-path finding = the record was read as
// an overlay. Both are the drift this control exists to catch.
const { errors } = gate(record(
{ sort: [{ field: 'amout', order: 'desc' }] },
{ sort: [{ field: 'also_not_a_field', order: 'asc' }] },
));
const sortFindings = errors.filter((e) => e.rule === SORT_FIELD_UNKNOWN);
expect(sortFindings, JSON.stringify(errors)).toHaveLength(1);
expect(sortFindings[0].path).toBe('views[0].config.sort[0]');
});
it('a FORM record\'s config declares no list-field surface and is not judged (#10001)', () => {
// The rung keys on `viewKind: 'list'`, mirroring the overlay rung and the
// wire union's own arms: a `form` record carries `FormViewSchema` config,
// which has no `sort` / `searchableFields` — a stray one riding in the
// stored body must not be judged by a list-view rule.
const { errors } = gate({
name: 'crm_case.edit',
object: 'crm_case',
viewKind: 'form',
config: { type: 'simple', fields: ['name'], sort: [{ field: 'amout', order: 'desc' }] },
});
expect(errors, JSON.stringify(errors)).toEqual([]);
});
// ── positive control: the pre-#10001 rungs behave EXACTLY as before ──
it('the flattened overlay is judged exactly once, on its top-level path — no record-rung leak (#10001)', () => {
// Passes on origin/main BEFORE the record rung and must keep passing
// after: one finding, top-level path. A `config`-rung leak into the
// overlay shape would move the path; a double judgment would add one.
const { errors } = gate(overlay({ sort: [{ field: 'amout', order: 'desc' }] }));
const sortFindings = errors.filter((e) => e.rule === SORT_FIELD_UNKNOWN);
expect(sortFindings, JSON.stringify(errors)).toHaveLength(1);
expect(sortFindings[0].path).toBe('views[0].sort[0]');
expect(sortFindings[0].where).toContain('flattened list overlay');
});
// ── the D4 differential, on the newly reachable rules ──
it('does not blame a `view` write for a stored object\'s own bad list view', () => {
// A context object whose built-in list view already violates the sort rule:
// present in BOTH gate passes, so its finding cancels in the differential
// and only the write's own (clean) declaration is judged.
const dirtyContext = [
{
...objects[0],
listViews: { aging: { type: 'grid', sort: [{ field: 'days_open', order: 'desc' }] } },
},
];
const result = runRuntimeAuthoringRules({
type: 'view',
item: overlay({ sort: [{ field: 'name', order: 'asc' }] }),
context: { objects: dirtyContext },
});
expect(result.errors, JSON.stringify(result.errors)).toEqual([]);
});
// ── the flow door is untouched by the member axis ──
it('a flow write is still judged by the suite\'s NON-view members', () => {
// `validateFlowNodeWrites` declares nothing and so keeps the default
// `flow` surface: a flow whose update_record node writes an unknown field
// is still refused. If the member axis had narrowed flow snapshots to the
// two view members, this zero-cost control would go green.
const badFlow = {
name: 'close_stale',
label: 'Close stale',
trigger: { type: 'record_change', object: 'crm_case', events: ['create'] },
nodes: [
{ id: 'start', type: 'start' },
{
id: 'upd',
type: 'update_record',
config: { object: 'crm_case', fields: { no_such_field: 'x' } },
},
],
};
const result = runRuntimeAuthoringRules({ type: 'flow', item: badFlow, context: { objects } });
expect(result.errors.map((e) => e.rule), JSON.stringify(result.errors)).toContain(
'flow-node-write-unknown-field',
);
});
});