@aerokit/test 15.14.0 → 15.15.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/README.md +12 -2
- package/package.json +1 -1
- package/src/api.js +1 -0
- package/src/fixtures.js +3 -1
- package/src/form.js +41 -6
- package/src/sample-values.js +15 -0
package/README.md
CHANGED
|
@@ -89,14 +89,24 @@ picked in the UI. A relation fixed after create (`fixedAfterCreate: true`) carri
|
|
|
89
89
|
picked on create and never changed by an edit, which the generated app answers with a 409. A field declaring a
|
|
90
90
|
`pattern:` gets a value of that shape rather than the marker string. An optional `pattern:` field
|
|
91
91
|
whose shape none of the sample candidates has (an IBAN, say) is left empty; a required one keeps the
|
|
92
|
-
marker, so the controller's 400 names the field.
|
|
92
|
+
marker, so the controller's 400 names the field. A `dependsOn` relation is sampled to match its
|
|
93
|
+
trigger, as the form narrows it: with `filterBy` alone the trigger is the row the dependent's `filterBy`
|
|
94
|
+
names; with `valueFrom` the dependent is the row whose `filterBy` (or key) equals the trigger row's
|
|
95
|
+
`valueFrom`. Relations an `agree` check names are picked so
|
|
93
96
|
their targets agree; a composite `uniqueKeys` entry is given a relation combination no live row
|
|
94
97
|
carries, and the rest and crud flows skip with the key named when every combination tried is taken.
|
|
98
|
+
A slots entity's start (the manifest's `slots` window) is a random grid start inside the hours, on
|
|
99
|
+
an open weekday of a far-future day, so the server's slot rules (a 400 off the window, a 409 for a
|
|
100
|
+
booked slot) never refuse it; the REST requests name `UTC` as their `X-Time-Zone`, the zone it is
|
|
101
|
+
computed in.
|
|
95
102
|
A relation carrying `pickable` is sampled only from the rows its rule admits — the chooser offers no
|
|
96
103
|
other, so a record built from one would never save — and one carrying `forbiddenTarget` only from
|
|
97
104
|
rows the record's own `forbidWhen` does not refuse, which is what the REST flow needs, having no
|
|
98
105
|
picker to read. When no row is left, a required relation fails naming the rule's own message rather
|
|
99
|
-
than "no rows".
|
|
106
|
+
than "no rows". A relation whose target is labelled by a `stampOn: issue` number carries
|
|
107
|
+
`labelDocumentNumber`: the picker shows every row not issued yet as the same "(draft)", so the sample
|
|
108
|
+
is a stamped row, picked by its number; with none stamped yet an optional relation is left unset and
|
|
109
|
+
a required one picks "(draft)".
|
|
100
110
|
|
|
101
111
|
## Custom UI hooks
|
|
102
112
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aerokit/test",
|
|
3
|
-
"version": "15.
|
|
3
|
+
"version": "15.15.0",
|
|
4
4
|
"description": "Generic Playwright runner that executes an intent module's generated <name>.test manifest against a running Eclipse Dirigible instance",
|
|
5
5
|
"license": "EPL-2.0",
|
|
6
6
|
"type": "module",
|
package/src/api.js
CHANGED
|
@@ -16,6 +16,7 @@ export function makeApi(request, manifest) {
|
|
|
16
16
|
// absolute controller path (a cross-model relation target owned by another module)
|
|
17
17
|
listPath: (path, limit = 20) => request.get(`${path}?$limit=${limit}`).then(asJson),
|
|
18
18
|
getPath: (path) => request.get(path).then(asJson),
|
|
19
|
+
searchPath: (path, conditions) => request.post(`${path}/search`, { data: { conditions } }).then(asJson),
|
|
19
20
|
count: (entity) => request.get(url(entity, '/count')).then(asJson).then((body) => (typeof body === 'number' ? body : body.count)),
|
|
20
21
|
get: (entity, id) => request.get(url(entity, '/' + id)).then(asJson),
|
|
21
22
|
getResponse: (entity, id) => request.get(url(entity, '/' + id)),
|
package/src/fixtures.js
CHANGED
|
@@ -5,7 +5,8 @@ import { login } from './session.js';
|
|
|
5
5
|
// Pre-authenticated Playwright test:
|
|
6
6
|
// - authState: worker-scoped form login, one session per worker
|
|
7
7
|
// - storageState: every page starts logged in
|
|
8
|
-
// - api: an APIRequestContext carrying the same session, for REST-level checks
|
|
8
|
+
// - api: an APIRequestContext carrying the same session, for REST-level checks; its writes name UTC as
|
|
9
|
+
// their zone, the one a sample slot start is computed in (dirigible #8097)
|
|
9
10
|
export const test = base.extend({
|
|
10
11
|
authState: [
|
|
11
12
|
async ({ browser }, use) => {
|
|
@@ -20,6 +21,7 @@ export const test = base.extend({
|
|
|
20
21
|
const request = await playwright.request.newContext({
|
|
21
22
|
baseURL: BASE_URL,
|
|
22
23
|
storageState: authState,
|
|
24
|
+
extraHTTPHeaders: { 'X-Time-Zone': 'UTC' },
|
|
23
25
|
});
|
|
24
26
|
await use(request);
|
|
25
27
|
await request.dispose();
|
package/src/form.js
CHANGED
|
@@ -66,8 +66,11 @@ export async function pickDropdown(page, relation, optionText) {
|
|
|
66
66
|
// - a dependsOn cascade forces CONSISTENT samples: the dependent row is chosen first and
|
|
67
67
|
// its filterBy FK becomes the trigger sibling's sample (independent first rows would
|
|
68
68
|
// pick e.g. Country=Afghanistan + City=Sofia, and the cascade then offers no options);
|
|
69
|
+
// a valueFrom dependent is the row the form narrows it to once the trigger is picked;
|
|
69
70
|
// - 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
|
+
// agreeing rows can be chosen here and a free key combination by freshUniqueKeys;
|
|
72
|
+
// - a target labelled by a stampOn: issue number (labelDocumentNumber) prefers a stamped row: the
|
|
73
|
+
// picker shows every unstamped one as the same draft marker (#7722, #7823).
|
|
71
74
|
export async function resolveRelationSamples(request, manifest, entity) {
|
|
72
75
|
const api = makeApi(request, manifest);
|
|
73
76
|
const idProperty = manifest.idProperty ?? 'Id';
|
|
@@ -87,7 +90,12 @@ export async function resolveRelationSamples(request, manifest, entity) {
|
|
|
87
90
|
// a filtered picker needs a matching candidate, so fetch a page and filter client-side; a
|
|
88
91
|
// constrained relation needs alternatives to choose from
|
|
89
92
|
const wide =
|
|
90
|
-
relation.where ||
|
|
93
|
+
relation.where ||
|
|
94
|
+
relation.leafOnly ||
|
|
95
|
+
relation.pickable ||
|
|
96
|
+
relation.forbiddenTarget ||
|
|
97
|
+
relation.labelDocumentNumber ||
|
|
98
|
+
constrained.has(relation.name);
|
|
91
99
|
const { rows: fetched, labelFrom } = await fetchRows(relation, wide ? 200 : 1);
|
|
92
100
|
let rows = relation.where ? fetched?.filter((r) => String(r[relation.where.by]) === String(relation.where.value)) : fetched;
|
|
93
101
|
// A picker rule narrows what the chooser OFFERS (dirigible #7663) and the child's own
|
|
@@ -106,6 +114,12 @@ export async function resolveRelationSamples(request, manifest, entity) {
|
|
|
106
114
|
const idProp = manifest.idProperty ?? 'Id';
|
|
107
115
|
rows = rows.filter((row) => !fetched.some((other) => other[prop] === row[idProp]));
|
|
108
116
|
}
|
|
117
|
+
if (rows?.length && relation.labelDocumentNumber) {
|
|
118
|
+
// an optional relation with no stamped row is left unset; a required one picks "(draft)"
|
|
119
|
+
const stamped = rows.filter((row) => !isDraftNumber(row[labelFrom]));
|
|
120
|
+
if (stamped.length) rows = stamped;
|
|
121
|
+
else if (!relation.required) continue;
|
|
122
|
+
}
|
|
109
123
|
if (!rows?.length) {
|
|
110
124
|
// a required FK cannot be satisfied - fail loudly; an optional one is simply left unset
|
|
111
125
|
if (relation.required) throw new Error(`Relation ${entity.name}.${relation.name}: ${noCandidateReason(relation)}`);
|
|
@@ -124,7 +138,8 @@ export async function resolveRelationSamples(request, manifest, entity) {
|
|
|
124
138
|
use(row) {
|
|
125
139
|
this.row = row;
|
|
126
140
|
this.id = row[idProperty];
|
|
127
|
-
this.label =
|
|
141
|
+
this.label =
|
|
142
|
+
relation.labelDocumentNumber && isDraftNumber(row[labelFrom]) ? DRAFT_MARKER : (row[labelFrom] ?? String(row[idProperty]));
|
|
128
143
|
},
|
|
129
144
|
};
|
|
130
145
|
sample.use(rows[0]);
|
|
@@ -134,6 +149,20 @@ export async function resolveRelationSamples(request, manifest, entity) {
|
|
|
134
149
|
// cascade consistency: re-point each dependsOn trigger at the row the dependent's choice implies
|
|
135
150
|
for (const sample of samples) {
|
|
136
151
|
const dependsOn = sample.relation.dependsOn;
|
|
152
|
+
if (dependsOn?.valueFrom) {
|
|
153
|
+
// The form copies the trigger row's valueFrom and offers only the rows whose filterBy (default:
|
|
154
|
+
// the key) holds it, so the dependent is that row, not its own first one (#7957).
|
|
155
|
+
const trigger = samples.find((s) => s.relation.name === dependsOn.relation);
|
|
156
|
+
const value = trigger?.row[dependsOn.valueFrom];
|
|
157
|
+
const path = sample.relation.apiAbsolute ?? (sample.relation.api ? manifest.restBase + sample.relation.api : null);
|
|
158
|
+
if (value == null || !path) continue; // nothing copied: the picker offers every row
|
|
159
|
+
const [row] = (await api.searchPath(path, [{ propertyName: dependsOn.filterBy ?? idProperty, operator: 'EQ', value }])) ?? [];
|
|
160
|
+
if (!row) continue;
|
|
161
|
+
sample.use(row);
|
|
162
|
+
sample.rows = [row];
|
|
163
|
+
trigger.rows = [trigger.row];
|
|
164
|
+
continue;
|
|
165
|
+
}
|
|
137
166
|
if (!dependsOn?.filterBy) continue;
|
|
138
167
|
const trigger = samples.find((s) => s.relation.name === dependsOn.relation);
|
|
139
168
|
// a cascade pair is chosen together - neither side may be swapped for a candidate later
|
|
@@ -145,10 +174,8 @@ export async function resolveRelationSamples(request, manifest, entity) {
|
|
|
145
174
|
if (!path) continue;
|
|
146
175
|
const row = await api.getPath(`${path}/${impliedId}`);
|
|
147
176
|
if (!row) continue;
|
|
148
|
-
trigger.row
|
|
177
|
+
trigger.use(row);
|
|
149
178
|
trigger.rows = [row];
|
|
150
|
-
trigger.id = impliedId;
|
|
151
|
-
trigger.label = row[trigger.relation.labelFrom ?? 'Name'];
|
|
152
179
|
}
|
|
153
180
|
alignAgreeing(entity, samples);
|
|
154
181
|
return samples;
|
|
@@ -190,6 +217,14 @@ export async function fillForm(page, manifest, entity, record, relationSamples,
|
|
|
190
217
|
for (const sample of [...triggers, ...dependents]) await pickDropdown(page, sample.relation, sample.label);
|
|
191
218
|
}
|
|
192
219
|
|
|
220
|
+
// The option text the picker gives a stampOn: issue number (HarmoniaFormat.documentNumber): a UUID
|
|
221
|
+
// placeholder - a document not issued yet - reads as the draft marker, any other value as itself.
|
|
222
|
+
// The marker is the English default; a walk in another UI language would need the translated one.
|
|
223
|
+
const DRAFT_MARKER = '(draft)';
|
|
224
|
+
function isDraftNumber(value) {
|
|
225
|
+
return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(String(value));
|
|
226
|
+
}
|
|
227
|
+
|
|
193
228
|
// One term of a picker rule or of a forbidWhen reaching through a relation, as the manifest writes
|
|
194
229
|
// it: `{ by, op, value }` with `op` one of eq / ne / present / absent, a presence test carrying no
|
|
195
230
|
// value. Compared as strings, because an id arrives as a number from REST and as text from a
|
package/src/sample-values.js
CHANGED
|
@@ -210,9 +210,24 @@ export function sampleRecord(entity) {
|
|
|
210
210
|
for (const name of set.slice(1)) delete record[name];
|
|
211
211
|
}
|
|
212
212
|
steerCompares(entity, record);
|
|
213
|
+
if (entity.slots) record[entity.slots.start] = slotSample(entity.slots);
|
|
213
214
|
return record;
|
|
214
215
|
}
|
|
215
216
|
|
|
217
|
+
// A slots entity's start (dirigible #8097): the server refuses one on a closed day, outside the
|
|
218
|
+
// hours or off the grid, and a second booking of a slot. A random free-looking slot - an open day of
|
|
219
|
+
// a far-future century, a grid start inside the hours - read in UTC, the zone the api fixture names.
|
|
220
|
+
function slotSample({ open, close, step, disabledDays = [] }) {
|
|
221
|
+
const minutes = (hhmm) => hhmm.split(':').reduce((h, m) => Number(h) * 60 + Number(m));
|
|
222
|
+
const from = minutes(open);
|
|
223
|
+
const slots = Math.max(1, Math.ceil((minutes(close) - from) / step));
|
|
224
|
+
let day = new Date(Date.UTC(2100, 0, 1) + Math.floor(Math.random() * 36500) * 86400000);
|
|
225
|
+
while (disabledDays.includes(day.getUTCDay())) day = new Date(day.getTime() + 86400000);
|
|
226
|
+
const at = from + step * Math.floor(Math.random() * slots);
|
|
227
|
+
const pad = (n) => String(n).padStart(2, '0');
|
|
228
|
+
return `${day.toISOString().slice(0, 10)}T${pad(Math.floor(at / 60))}:${pad(at % 60)}:00Z`;
|
|
229
|
+
}
|
|
230
|
+
|
|
216
231
|
// A compare check relates the record's own field to a second value - another of its fields, or a
|
|
217
232
|
// literal - and the sample values above are per-type constants, so two dates come out EQUAL and a
|
|
218
233
|
// strict comparison (gt/lt/ne) would be rejected with 400, just as a sample quantity of 7 fails a
|