@aerokit/test 0.0.0-stage → 15.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/form.js ADDED
@@ -0,0 +1,214 @@
1
+ import { makeApi } from './api.js';
2
+ import { expect } from './fixtures.js';
3
+ import { editableFields, handleField } from './sample-values.js';
4
+
5
+ // Generated form controls: every field input has id="f_<Name>"; a to-one relation is a
6
+ // Harmonia x-h-select whose input holds the value and whose options carry the label.
7
+ export async function fillField(page, field, value, opts = {}) {
8
+ const custom = opts.extend?.widgets?.[field.widget];
9
+ if (custom) return custom(page, field, value);
10
+ const input = page.locator('#f_' + field.name);
11
+ if (field.type === 'boolean') return input.setChecked(!!value);
12
+ if (field.type === 'timestamp' || field.type === 'datetime') {
13
+ // the sample is a full ISO instant (what the REST layer binds); a datetime-local input
14
+ // takes the zone-less YYYY-MM-DDTHH:mm prefix
15
+ return input.fill(String(value).slice(0, 16));
16
+ }
17
+ await input.fill(String(value));
18
+ }
19
+
20
+ // The x-h-select directive hides its input and appends a button[role=combobox] trigger beside it;
21
+ // options carry role=option.
22
+ // The generated manage/document forms give that input id="f_<Name>", so the trigger is reached
23
+ // through its sibling - independent of how the field label reads. The accessible name is the
24
+ // fallback for a form whose select input carries no id (the my/partner surfaces).
25
+ async function relationTrigger(page, relation) {
26
+ const input = page.locator('#f_' + relation.name);
27
+ if (await input.count()) return input.locator('xpath=../button[@role="combobox"]');
28
+ // Anchored prefix match: the combobox accessible name is the label plus the placeholder or
29
+ // selected value ("Country Select a Country..."), so exact matching finds nothing - while a
30
+ // bare substring match collides with longer sibling labels ("Type" also hits "Chart Type").
31
+ const label = relation.label ?? relation.name;
32
+ const anchored = new RegExp('^' + label.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '\\b');
33
+ return page.getByRole('combobox', { name: anchored }).first();
34
+ }
35
+
36
+ export async function pickDropdown(page, relation, optionText) {
37
+ const trigger = await relationTrigger(page, relation);
38
+ const input = page.locator('#f_' + relation.name);
39
+ // visible only: an option list that is closing, or another select's, must not be the one clicked
40
+ const option = page.getByRole('option', { name: optionText }).filter({ visible: true }).first();
41
+ for (let attempt = 0; ; attempt++) {
42
+ await trigger.click();
43
+ try {
44
+ await option.click({ timeout: 10_000 });
45
+ } catch {
46
+ // the option list re-rendered mid-click (async option load reflow) - reopen and retry
47
+ await trigger.click();
48
+ await option.click({ force: true });
49
+ }
50
+ // The list must be closed before the next relation's trigger is clicked: a click landing while
51
+ // it is still open only dismisses it, and that select stays empty - the second of two relations
52
+ // to the same entity was left at its placeholder that way (dirigible #7545).
53
+ await expect(option).toBeHidden();
54
+ if (!(await input.count()) || (await input.inputValue()) !== '' || attempt > 0) break;
55
+ }
56
+ if (await input.count()) await expect(input, `${relation.name} must hold the picked "${optionText}"`).not.toHaveValue('');
57
+ }
58
+
59
+ // Resolve a live option for each to-one relation: take the first suitable row of the
60
+ // target entity and use its label field's value as the visible option text.
61
+ // - an entityStatus relation is skipped: it renders as a status pill (not an editable
62
+ // input in any form) and its value comes from the init: DB default;
63
+ // - a cross-model relation's rows come from its apiAbsolute controller URL (the target
64
+ // lives in another module and is not in this manifest);
65
+ // - a where: option filter narrows the candidate rows to matching ones;
66
+ // - a dependsOn cascade forces CONSISTENT samples: the dependent row is chosen first and
67
+ // its filterBy FK becomes the trigger sibling's sample (independent first rows would
68
+ // pick e.g. Country=Afghanistan + City=Sofia, and the cascade then offers no options);
69
+ // - a relation an agree check or a composite unique key names keeps a page of candidate rows, so
70
+ // agreeing rows can be chosen here and a free key combination by freshUniqueKeys.
71
+ export async function resolveRelationSamples(request, manifest, entity) {
72
+ const api = makeApi(request, manifest);
73
+ const idProperty = manifest.idProperty ?? 'Id';
74
+
75
+ async function fetchRows(relation, limit) {
76
+ if (relation.apiAbsolute) return { rows: await api.listPath(relation.apiAbsolute, limit), labelFrom: relation.labelFrom ?? 'Name' };
77
+ if (relation.api) return { rows: await api.listPath(manifest.restBase + relation.api, limit), labelFrom: relation.labelFrom ?? 'Name' };
78
+ const target = manifest.entities.find((e) => e.name === relation.to);
79
+ if (!target) throw new Error(`Relation ${entity.name}.${relation.name}: target ${relation.to} not in manifest`);
80
+ return { rows: await api.list(target, limit), labelFrom: relation.labelFrom ?? handleField(target)?.name ?? 'Name' };
81
+ }
82
+
83
+ const constrained = new Set([...(entity.agree ?? []).flatMap((check) => check.relations), ...(entity.uniqueKeys ?? []).flat()]);
84
+ const samples = [];
85
+ for (const relation of entity.relations ?? []) {
86
+ if (relation.entityStatus) continue;
87
+ // a filtered picker needs a matching candidate, so fetch a page and filter client-side; a
88
+ // constrained relation needs alternatives to choose from
89
+ const wide =
90
+ relation.where || relation.leafOnly || relation.pickable || relation.forbiddenTarget || constrained.has(relation.name);
91
+ const { rows: fetched, labelFrom } = await fetchRows(relation, wide ? 200 : 1);
92
+ let rows = relation.where ? fetched?.filter((r) => String(r[relation.where.by]) === String(relation.where.value)) : fetched;
93
+ // A picker rule narrows what the chooser OFFERS (dirigible #7663) and the child's own
94
+ // forbidWhen decides which parent it accepts at all (#7667). Both refuse a row the manifest
95
+ // otherwise describes as a candidate, so the sample has to respect them or the record is built
96
+ // from a row the UI never offers and the REST create is answered 400.
97
+ if (rows?.length && relation.pickable?.when?.length) {
98
+ rows = rows.filter((row) => relation.pickable.when.every((term) => termHolds(row, term)));
99
+ }
100
+ if (rows?.length && relation.forbiddenTarget?.length) {
101
+ rows = rows.filter((row) => !relation.forbiddenTarget.some((term) => termHolds(row, term)));
102
+ }
103
+ if (relation.leafOnly && rows?.length) {
104
+ // the generated validation rejects a non-leaf target - pick a row no other row parents
105
+ const prop = relation.leafOnly.hierarchyProperty;
106
+ const idProp = manifest.idProperty ?? 'Id';
107
+ rows = rows.filter((row) => !fetched.some((other) => other[prop] === row[idProp]));
108
+ }
109
+ if (!rows?.length) {
110
+ // a required FK cannot be satisfied - fail loudly; an optional one is simply left unset
111
+ if (relation.required) throw new Error(`Relation ${entity.name}.${relation.name}: ${noCandidateReason(relation)}`);
112
+ continue;
113
+ }
114
+ const label = rows[0][labelFrom];
115
+ if (label == null && !relation.required) {
116
+ // no display label to pick by (the target has no name-like field) - leave the optional
117
+ // relation unset rather than clicking blind
118
+ continue;
119
+ }
120
+ const sample = {
121
+ relation,
122
+ rows,
123
+ // re-point the sample at another of its candidate rows
124
+ use(row) {
125
+ this.row = row;
126
+ this.id = row[idProperty];
127
+ this.label = row[labelFrom] ?? String(row[idProperty]);
128
+ },
129
+ };
130
+ sample.use(rows[0]);
131
+ samples.push(sample);
132
+ }
133
+
134
+ // cascade consistency: re-point each dependsOn trigger at the row the dependent's choice implies
135
+ for (const sample of samples) {
136
+ const dependsOn = sample.relation.dependsOn;
137
+ if (!dependsOn?.filterBy) continue;
138
+ const trigger = samples.find((s) => s.relation.name === dependsOn.relation);
139
+ // a cascade pair is chosen together - neither side may be swapped for a candidate later
140
+ sample.rows = [sample.row];
141
+ if (trigger) trigger.rows = [trigger.row];
142
+ const impliedId = sample.row[dependsOn.filterBy];
143
+ if (!trigger || impliedId == null || trigger.id === impliedId) continue;
144
+ const path = trigger.relation.apiAbsolute ?? (trigger.relation.api ? manifest.restBase + trigger.relation.api : null);
145
+ if (!path) continue;
146
+ const row = await api.getPath(`${path}/${impliedId}`);
147
+ if (!row) continue;
148
+ trigger.row = row;
149
+ trigger.rows = [row];
150
+ trigger.id = impliedId;
151
+ trigger.label = row[trigger.relation.labelFrom ?? 'Name'];
152
+ }
153
+ alignAgreeing(entity, samples);
154
+ return samples;
155
+ }
156
+
157
+ // An agree check refuses a record whose two relations point at targets that differ on onProperty
158
+ // (a transfer between stores of two companies), and the first row of each target is an arbitrary
159
+ // pair. Choose the first lead row for which every other side has a candidate carrying the same value.
160
+ function alignAgreeing(entity, samples) {
161
+ for (const check of entity.agree ?? []) {
162
+ const sides = check.relations.map((name) => samples.find((s) => s.relation.name === name));
163
+ if (sides.some((side) => !side)) continue; // an unset side has nothing to disagree about
164
+ const [lead, ...others] = sides;
165
+ for (const row of lead.rows) {
166
+ const value = row[check.onProperty];
167
+ if (value == null) continue;
168
+ const matches = others.map((side) => side.rows.find((candidate) => String(candidate[check.onProperty]) === String(value)));
169
+ if (matches.some((match) => !match)) continue;
170
+ lead.use(row);
171
+ others.forEach((side, i) => side.use(matches[i]));
172
+ break;
173
+ }
174
+ }
175
+ }
176
+
177
+ export async function fillForm(page, manifest, entity, record, relationSamples, opts = {}) {
178
+ for (const field of editableFields(entity)) {
179
+ if (record[field.name] === undefined) continue;
180
+ await fillField(page, field, record[field.name], opts);
181
+ }
182
+ // a readOnly relation renders as a disabled combobox (an update-time calculated action, a
183
+ // platform-owned FK) - the server sets it, so there is nothing to pick (dirigible #7554); the
184
+ // REST flows still post its sample, which the calculated action overwrites or checks
185
+ const pickable = relationSamples.filter((s) => !s.relation.readOnly);
186
+ // cascade order: a dependsOn trigger must be picked BEFORE its dependent, so the narrowed
187
+ // option list is the one the dependent's sample was chosen from
188
+ const triggers = pickable.filter((s) => !s.relation.dependsOn);
189
+ const dependents = pickable.filter((s) => s.relation.dependsOn);
190
+ for (const sample of [...triggers, ...dependents]) await pickDropdown(page, sample.relation, sample.label);
191
+ }
192
+
193
+ // One term of a picker rule or of a forbidWhen reaching through a relation, as the manifest writes
194
+ // it: `{ by, op, value }` with `op` one of eq / ne / present / absent, a presence test carrying no
195
+ // value. Compared as strings, because an id arrives as a number from REST and as text from a
196
+ // rendered option.
197
+ function termHolds(row, term) {
198
+ const value = row?.[term.by];
199
+ const empty = value == null || String(value).trim() === '';
200
+ if (term.op === 'present') return !empty;
201
+ if (term.op === 'absent') return empty;
202
+ if (term.op === 'ne') return String(value) !== String(term.value);
203
+ return String(value) === String(term.value);
204
+ }
205
+
206
+ // Why a required relation had nothing to pick: the rule that excluded every row, in the words the
207
+ // model authored it in, so the failure names the business rule rather than "no rows".
208
+ function noCandidateReason(relation) {
209
+ const refusal = (relation.forbiddenTarget ?? []).find((term) => term.message)?.message;
210
+ if (refusal) return `every ${relation.to} is refused - ${refusal}`;
211
+ if (relation.forbiddenTarget?.length) return `every ${relation.to} is in a state this record refuses`;
212
+ if (relation.pickable?.when?.length) return `no ${relation.to} row satisfies the picker rule`;
213
+ return `no ${relation.to} rows to pick from`;
214
+ }
package/src/index.js ADDED
@@ -0,0 +1,30 @@
1
+ import fs from 'node:fs';
2
+ import { test } from './fixtures.js';
3
+ import { a11yFlow } from './flows/a11y.js';
4
+ import { crudFlow } from './flows/crud.js';
5
+ import { listFlow } from './flows/list.js';
6
+ import { multilingualFlow } from './flows/multilingual.js';
7
+ import { myFlow } from './flows/my.js';
8
+ import { restFlow } from './flows/rest.js';
9
+ import { shellFlow } from './flows/shell.js';
10
+
11
+ // Entry point: execute a module's generated <name>.test manifest. Accepts the parsed
12
+ // manifest object or a path to the file. opts.extend hosts the custom-UI hooks (widget
13
+ // fillers, per-entity skip lists, before/afterCreate).
14
+ export function runTest(manifestRef, opts = {}) {
15
+ const manifest = typeof manifestRef === 'string' ? JSON.parse(fs.readFileSync(manifestRef, 'utf8')) : manifestRef;
16
+ for (const entity of manifest.entities ?? []) {
17
+ test.describe(`${manifest.module} / ${entity.name}`, () => {
18
+ listFlow(manifest, entity, opts);
19
+ crudFlow(manifest, entity, opts);
20
+ restFlow(manifest, entity, opts);
21
+ myFlow(manifest, entity, opts);
22
+ multilingualFlow(manifest, entity, opts);
23
+ shellFlow(manifest, entity, opts);
24
+ a11yFlow(manifest, entity, opts);
25
+ });
26
+ }
27
+ }
28
+
29
+ // Deprecated alias - the pre-subpath name; use runTest.
30
+ export const runAppTest = runTest;
@@ -0,0 +1,287 @@
1
+ const ALPHA = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
2
+ const LOWER = ALPHA.toLowerCase();
3
+ const DIGITS = '0123456789';
4
+
5
+ function rand(chars, n) {
6
+ let out = '';
7
+ for (let i = 0; i < n; i++) out += chars[Math.floor(Math.random() * chars.length)];
8
+ return out;
9
+ }
10
+
11
+ // Type-aware sample value for one field. Long strings carry the APPTEST- prefix (the
12
+ // cleanup marker and the searchable handle); short strings always contain a digit so
13
+ // they can never collide with letter-only nomenclature seeds (ISO codes etc.).
14
+ export function sampleValue(field) {
15
+ if (field.unique && field.type !== 'string') return uniqueValue(field);
16
+ switch (field.type) {
17
+ case 'string': {
18
+ const len = field.length ?? 64;
19
+ const value = len >= 16 ? 'APPTEST-' + rand(ALPHA + DIGITS, 6) : rand(ALPHA, Math.max(1, len - 1)) + rand(DIGITS, 1);
20
+ return field.pattern ? shaped(field, value) : value;
21
+ }
22
+ case 'integer':
23
+ case 'bigint':
24
+ return 7;
25
+ case 'decimal':
26
+ case 'double':
27
+ return 3.14;
28
+ case 'boolean':
29
+ return true;
30
+ case 'date':
31
+ return '2026-07-08';
32
+ case 'timestamp':
33
+ case 'datetime':
34
+ // full ISO instant: the generated Java entities bind java.time.Instant, which rejects a
35
+ // zone-less value; the UI fill slices this to the datetime-local shape
36
+ return '2026-07-08T10:00:00Z';
37
+ // the input shapes of an <input type="month|week"> - a month column is VARCHAR(7), so the
38
+ // default marker overflows it (dirigible #7545)
39
+ case 'month':
40
+ return '2026-07';
41
+ case 'week':
42
+ return '2026-W28';
43
+ default:
44
+ return 'APPTEST-' + rand(ALPHA + DIGITS, 6);
45
+ }
46
+ }
47
+
48
+ // A `pattern:` field (an authored regex, or the address regex behind `format: email`) is rejected
49
+ // by the generated controller with 400 unless the value has the declared shape, so the marker value
50
+ // above has to be traded for one that matches: the candidates are tried in order and the first that
51
+ // both matches and fits the declared length wins. When nothing here matches (an IBAN-shaped regex
52
+ // mixing character classes by position), an optional field is left unset - the record is valid
53
+ // without it, and no flow needs it filled (dirigible #7525) - while a required one keeps the plain
54
+ // marker: the controller's own 400 then names the field and the pattern, which is the message the
55
+ // module author needs, and a silent near-miss would not.
56
+ function shaped(field, value) {
57
+ let regex;
58
+ try {
59
+ // anchored like the generated controller's own String.matches, so an unanchored pattern cannot
60
+ // pass here on a substring and then be refused by the server
61
+ regex = new RegExp('^(?:' + field.pattern + ')$');
62
+ } catch {
63
+ return value; // not a JavaScript-parsable regex - nothing to shape the value to
64
+ }
65
+ const token = value.replace(/[^A-Za-z0-9]/g, '');
66
+ const candidates = [
67
+ value,
68
+ `apptest.${token}@apptest.example.com`, // format: email and other address-shaped patterns
69
+ token, // letters and digits, no separator
70
+ rand(DIGITS, 10), // a numeric code
71
+ // a letters-only code (an ISO language or country code, ^[a-z]{2,3}$) - kept short, the shape
72
+ // such a pattern declares, and within the declared length (dirigible #7545)
73
+ rand(LOWER, Math.min(field.length ?? 3, 3)),
74
+ rand(ALPHA, Math.min(field.length ?? 3, 3)),
75
+ ];
76
+ const match = candidates.find((candidate) => candidate.length <= (field.length ?? 255) && regex.test(candidate));
77
+ return match ?? (field.required ? value : undefined);
78
+ }
79
+
80
+ // A `unique:` field refuses a second row carrying its value with 409 - the module's guard doing its
81
+ // job. The per-type constants above are the one value certain to collide: with a seeded row, with
82
+ // the row a parallel flow of the same run is creating, or with one an earlier failed run left
83
+ // behind (dirigible #7530). So a unique field draws a fresh value per record, away from the range
84
+ // an author seeds (a year, a small code): an integer in the hundred-thousands, a date in the 22nd
85
+ // century. Strings need none of this - the marker above is already random.
86
+ function uniqueValue(field) {
87
+ const n = 100000 + Math.floor(Math.random() * 900000);
88
+ switch (field.type) {
89
+ case 'integer':
90
+ case 'bigint':
91
+ return n;
92
+ case 'decimal':
93
+ case 'double':
94
+ return n + 0.25;
95
+ case 'date':
96
+ case 'timestamp':
97
+ case 'datetime': {
98
+ const at = new Date(Date.UTC(2100, 0, 1) + (n % 36500) * 86400000);
99
+ return field.type === 'date' ? at.toISOString().slice(0, 10) : at.toISOString().replace(/\.\d{3}Z$/, 'Z');
100
+ }
101
+ case 'month':
102
+ return `${2100 + (n % 900)}-${String(1 + (n % 12)).padStart(2, '0')}`;
103
+ case 'week':
104
+ return `${2100 + (n % 900)}-W${String(1 + (n % 52)).padStart(2, '0')}`;
105
+ default:
106
+ return sampleValue({ ...field, unique: false });
107
+ }
108
+ }
109
+
110
+ // A drawn value is unlikely to collide, not certain not to - probe the live rows for each unique
111
+ // field of the record through the controller's filtered read and redraw on a hit, so the create the
112
+ // flow is about to send cannot be refused for a value that is already taken. The value goes as a
113
+ // string - the controller coerces it to the property's own type (an Integer would not bind to a Long
114
+ // or BigDecimal column). A probe that fails (an older controller without /search) keeps the value.
115
+ export async function freshUniques(client, entity, record) {
116
+ let redrawn = false;
117
+ for (const field of editableFields(entity)) {
118
+ if (!field.unique || !(field.name in record)) continue;
119
+ for (let attempt = 0; attempt < 5; attempt++) {
120
+ let rows;
121
+ try {
122
+ rows = await client.search(entity, [{ propertyName: field.name, operator: 'EQ', value: String(record[field.name]) }]);
123
+ } catch {
124
+ break;
125
+ }
126
+ if (!rows?.length) break;
127
+ record[field.name] = field.type === 'string' ? sampleValue(field) : uniqueValue(field);
128
+ redrawn = true;
129
+ }
130
+ }
131
+ // a redrawn field may be the right-hand side of a compare check - re-derive its left operand
132
+ if (redrawn) steerCompares(entity, record);
133
+ return record;
134
+ }
135
+
136
+ // A composite unique key (`unique: [ProjectTimesheet, Employee]` - one timesheet per employee per
137
+ // project-month) refuses a second row carrying the same combination with 409, and the first row of
138
+ // each relation is the combination a seed most likely holds already (dirigible #7545). Walk the
139
+ // candidate rows of the key's relations (and redraw its own fields) until the controller's filtered
140
+ // read finds no row with that combination, keeping every agree check satisfied. Answers the key no
141
+ // free combination was found for - the flow skips with it - or null when the record is free. Call it
142
+ // before the relation samples are copied into the record.
143
+ export async function freshUniqueKeys(client, entity, record, samples) {
144
+ for (const key of entity.uniqueKeys ?? []) {
145
+ const related = key.map((name) => samples.find((s) => s.relation.name === name)).filter(Boolean);
146
+ const drawn = editableFields(entity).filter((f) => key.includes(f.name) && f.name in record);
147
+ // a member left unset is NULL, and a NULL never collides
148
+ if (related.length + drawn.length < key.length) continue;
149
+ let free = false;
150
+ for (const combination of combinations(related.map((s) => s.rows), related.length ? 50 : 5)) {
151
+ combination.forEach((row, i) => related[i].use(row));
152
+ if (!agreeing(entity, samples)) continue;
153
+ const value = (name) => related.find((s) => s.relation.name === name)?.id ?? record[name];
154
+ let rows;
155
+ try {
156
+ rows = await client.search(entity, key.map((name) => ({ propertyName: name, operator: 'EQ', value: String(value(name)) })));
157
+ } catch {
158
+ free = true; // an older controller without /search - keep the combination, as freshUniques does
159
+ break;
160
+ }
161
+ if (!rows?.length) {
162
+ free = true;
163
+ break;
164
+ }
165
+ for (const field of drawn) record[field.name] = field.type === 'string' ? sampleValue(field) : uniqueValue(field);
166
+ }
167
+ if (!free) return key;
168
+ if (drawn.length) steerCompares(entity, record);
169
+ }
170
+ return null;
171
+ }
172
+
173
+ // The first `limit` entries of the cartesian product of the candidate lists, in order - so the first
174
+ // combination is the one the samples started with. No lists yields `limit` empty combinations: a key
175
+ // of own fields only is retried by redrawing them.
176
+ function* combinations(lists, limit) {
177
+ const index = lists.map(() => 0);
178
+ for (let produced = 0; produced < limit; produced++) {
179
+ yield lists.map((list, i) => list[index[i]]);
180
+ let i = lists.length - 1;
181
+ while (i >= 0 && ++index[i] === lists[i].length) index[i--] = 0;
182
+ if (lists.length && i < 0) return;
183
+ }
184
+ }
185
+
186
+ // Whether the relation samples satisfy every agree check of the entity (an unset side has nothing to
187
+ // disagree about).
188
+ export function agreeing(entity, samples) {
189
+ return (entity.agree ?? []).every((check) => {
190
+ const values = check.relations.map((name) => samples.find((s) => s.relation.name === name)?.row?.[check.onProperty]);
191
+ return values.some((value) => value == null) || values.every((value) => String(value) === String(values[0]));
192
+ });
193
+ }
194
+
195
+ export function editableFields(entity) {
196
+ return (entity.fields ?? []).filter((f) => !f.readOnly && !f.primaryKey && !f.generated);
197
+ }
198
+
199
+ // One sample record for the entity's own fields (relations are resolved separately,
200
+ // against live target rows).
201
+ export function sampleRecord(entity) {
202
+ const record = {};
203
+ for (const field of editableFields(entity)) {
204
+ const value = sampleValue(field);
205
+ if (value !== undefined) record[field.name] = value;
206
+ }
207
+ // an exactlyOne check rejects a record where more than one of the named fields is set -
208
+ // keep only the first of each declared set
209
+ for (const set of entity.exactlyOne ?? []) {
210
+ for (const name of set.slice(1)) delete record[name];
211
+ }
212
+ steerCompares(entity, record);
213
+ return record;
214
+ }
215
+
216
+ // A compare check relates the record's own field to a second value - another of its fields, or a
217
+ // literal - and the sample values above are per-type constants, so two dates come out EQUAL and a
218
+ // strict comparison (gt/lt/ne) would be rejected with 400, just as a sample quantity of 7 fails a
219
+ // `le 5`. Derive the left operand from whichever right-hand side the check names, by the smallest
220
+ // step that satisfies the declared operator (equality satisfies ge/le/eq).
221
+ function steerCompares(entity, record) {
222
+ for (const check of entity.compare ?? []) {
223
+ if (!(check.field in record)) continue;
224
+ const type = (entity.fields ?? []).find((f) => f.name === check.field)?.type;
225
+ const right = 'than' in check ? record[check.than] : literalValue(check.value, type);
226
+ if (right == null) continue;
227
+ record[check.field] = shifted(right, type, STEPS[check.op] ?? 0);
228
+ }
229
+ return record;
230
+ }
231
+
232
+ // The right-hand side of a compare check declared as a literal. A moment (CURRENT_DATE /
233
+ // CURRENT_TIMESTAMP / NOW) resolves against the runner's own clock, in the field's shape; a moment
234
+ // carrying an offset is left alone - the sample record keeps its constant and the check is simply
235
+ // not steered, which is safe for the ge/le/eq that a stale sample still satisfies.
236
+ function literalValue(value, type) {
237
+ if (typeof value !== 'string') return value;
238
+ const now = new Date();
239
+ switch (value.trim()) {
240
+ case 'CURRENT_DATE':
241
+ return type === 'date' ? now.toISOString().slice(0, 10) : now.toISOString().replace(/\.\d{3}Z$/, 'Z');
242
+ case 'CURRENT_TIMESTAMP':
243
+ case 'NOW':
244
+ return now.toISOString().replace(/\.\d{3}Z$/, 'Z');
245
+ default:
246
+ return /^(CURRENT_DATE|CURRENT_TIMESTAMP|NOW)[+-]/.test(value.trim()) ? null : value;
247
+ }
248
+ }
249
+
250
+ // How far the left operand of a compare check has to move off the right one to satisfy it.
251
+ const STEPS = { ge: 0, le: 0, eq: 0, gt: 1, ne: 1, lt: -1 };
252
+
253
+ // One step of the value's own unit: a day for a date, an hour for a timestamp, one for a number.
254
+ function shifted(value, type, step) {
255
+ if (step === 0) return value;
256
+ switch (type) {
257
+ case 'date': {
258
+ const at = new Date(value + 'T00:00:00Z');
259
+ at.setUTCDate(at.getUTCDate() + step);
260
+ return at.toISOString().slice(0, 10);
261
+ }
262
+ case 'timestamp':
263
+ case 'datetime': {
264
+ const at = new Date(value);
265
+ at.setUTCHours(at.getUTCHours() + step);
266
+ return at.toISOString().replace(/\.\d{3}Z$/, 'Z');
267
+ }
268
+ default:
269
+ return value + step;
270
+ }
271
+ }
272
+
273
+ // The searchable "handle" field: the first long string field shown in the list. Its
274
+ // value identifies the record in the table across the create/edit/delete flow, and the flows
275
+ // flip it by appending a suffix - so a field carrying a `pattern:` cannot be it, the suffix
276
+ // breaking the very shape the controller enforces. Read-only fields are out through
277
+ // editableFields: a `number:` field is the platform's to stamp and preserve, so a write to it
278
+ // reads back unchanged (dirigible #7411).
279
+ // Null when the entity has no such field (all-numeric/date entities) - flows degrade:
280
+ // the UI walk is skipped and the REST flow drops its update-value assertion.
281
+ export function handleField(entity) {
282
+ return editableFields(entity).find((f) => f.type === 'string' && (f.length ?? 64) >= 16 && f.major !== false && !f.pattern) ?? null;
283
+ }
284
+
285
+ export function labelOf(field) {
286
+ return field.label ?? field.name;
287
+ }
package/src/session.js ADDED
@@ -0,0 +1,19 @@
1
+ import { BASE_URL, PASSWORD, USERNAME } from './env.js';
2
+
3
+ // Dirigible uses form login (basic auth is not accepted by the login flow). Logs in once
4
+ // per worker and returns a storageState (session cookie) reused by every test and by the
5
+ // REST client.
6
+ export async function login(browser) {
7
+ const context = await browser.newContext({ baseURL: BASE_URL });
8
+ const page = await context.newPage();
9
+ await page.goto('/login');
10
+ await page.fill('input[name="username"]', USERNAME);
11
+ await page.fill('input[name="password"]', PASSWORD);
12
+ await Promise.all([
13
+ page.waitForURL((url) => !url.pathname.includes('/login')),
14
+ page.click('button[type="submit"]'),
15
+ ]);
16
+ const state = await context.storageState();
17
+ await context.close();
18
+ return state;
19
+ }