@aerokit/test 0.0.0-stage → 15.4.1
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/LICENSE +277 -0
- package/README.md +108 -2
- package/package.json +34 -4
- package/src/api.js +41 -0
- package/src/env.js +3 -0
- package/src/fixtures.js +29 -0
- package/src/flows/a11y.js +86 -0
- package/src/flows/crud.js +121 -0
- package/src/flows/list.js +82 -0
- package/src/flows/multilingual.js +68 -0
- package/src/flows/my.js +156 -0
- package/src/flows/rest.js +57 -0
- package/src/flows/shell.js +17 -0
- package/src/form.js +214 -0
- package/src/index.js +30 -0
- package/src/sample-values.js +287 -0
- package/src/session.js +19 -0
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
|
+
}
|