@aerokit/sdk 14.67.0 → 14.69.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/package.json +1 -1
- package/test/flows/crud.js +2 -2
- package/test/flows/multilingual.js +45 -6
- package/test/flows/my.js +2 -1
- package/test/flows/rest.js +2 -1
- package/test/sample-values.js +73 -9
package/package.json
CHANGED
package/test/flows/crud.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { expect, test } from '../fixtures.js';
|
|
2
2
|
import { makeApi } from '../api.js';
|
|
3
3
|
import { fillField, fillForm, resolveRelationSamples } from '../form.js';
|
|
4
|
-
import { handleField, sampleRecord } from '../sample-values.js';
|
|
4
|
+
import { freshUniques, handleField, sampleRecord } from '../sample-values.js';
|
|
5
5
|
|
|
6
6
|
// Server-side row lookup via the toolbar "Search <Entity>..." box - present on every list
|
|
7
7
|
// layout (manage-list, master-detail, document) and searching the string columns server-side.
|
|
@@ -44,7 +44,7 @@ export function crudFlow(manifest, entity, opts = {}) {
|
|
|
44
44
|
if (!handleField(entity)) return;
|
|
45
45
|
|
|
46
46
|
test(`${entity.name}: create, edit and delete through the UI`, async ({ page, api }) => {
|
|
47
|
-
const record = sampleRecord(entity);
|
|
47
|
+
const record = await freshUniques(makeApi(api, manifest), entity, sampleRecord(entity));
|
|
48
48
|
const handle = handleField(entity);
|
|
49
49
|
const relationSamples = await resolveRelationSamples(api, manifest, entity);
|
|
50
50
|
|
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
import { expect, test } from '../fixtures.js';
|
|
2
2
|
import { listCrumb } from './list.js';
|
|
3
3
|
|
|
4
|
+
const LANGUAGE_KEY = 'codbex.harmonia.language';
|
|
5
|
+
// A reload in another language is three round trips on a cold instance - the page, the list's
|
|
6
|
+
// data GET and the translation overlay behind it - so the 5 s default flaked on slower runners.
|
|
7
|
+
const OVERLAY_TIMEOUT = 15_000;
|
|
8
|
+
|
|
4
9
|
// The read-time translation overlay: switch the shared language key (what the Region &
|
|
5
10
|
// Language setting writes), reload, and a known seed row shows its translated name.
|
|
6
11
|
// Needs a concrete sample in the manifest: { language, base, translated }.
|
|
@@ -11,11 +16,45 @@ export function multilingualFlow(manifest, entity) {
|
|
|
11
16
|
test(`${entity.name}: ${sample.language} translation overlays on read`, async ({ page }) => {
|
|
12
17
|
await page.goto(manifest.standaloneShell + entity.route);
|
|
13
18
|
await expect(listCrumb(page, entity)).toBeVisible();
|
|
14
|
-
|
|
15
|
-
await page.
|
|
16
|
-
await
|
|
17
|
-
await page
|
|
18
|
-
await page.reload();
|
|
19
|
-
await expect(page.locator('tbody tr', { hasText: sample.base }).first()).toBeVisible();
|
|
19
|
+
const translatedRead = await reloadIn(page, manifest, entity, sample.language);
|
|
20
|
+
await expectRowShowing(page, sample.translated, translatedRead);
|
|
21
|
+
const baseRead = await reloadIn(page, manifest, entity, 'en');
|
|
22
|
+
await expectRowShowing(page, sample.base, baseRead);
|
|
20
23
|
});
|
|
21
24
|
}
|
|
25
|
+
|
|
26
|
+
// Set the language key and reload, waiting for the list's own data GET (the paged getAll on the
|
|
27
|
+
// entity's controller) so the assertion starts once the rows are on their way, and answering what
|
|
28
|
+
// that read asked for and got back - which is what tells a slow render from a missing translation.
|
|
29
|
+
async function reloadIn(page, manifest, entity, language) {
|
|
30
|
+
await page.evaluate(([key, lang]) => localStorage.setItem(key, lang), [LANGUAGE_KEY, language]);
|
|
31
|
+
const controllerPath = manifest.restBase + entity.api;
|
|
32
|
+
const listRead = page.waitForResponse(
|
|
33
|
+
(response) => response.request().method() === 'GET' && new URL(response.url()).pathname === controllerPath,
|
|
34
|
+
{ timeout: OVERLAY_TIMEOUT },
|
|
35
|
+
);
|
|
36
|
+
await page.reload();
|
|
37
|
+
const response = await listRead;
|
|
38
|
+
return {
|
|
39
|
+
language,
|
|
40
|
+
acceptLanguage: await response.request().headerValue('accept-language'),
|
|
41
|
+
status: response.status(),
|
|
42
|
+
body: await response.text(),
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async function expectRowShowing(page, text, read) {
|
|
47
|
+
const rows = page.locator('tbody tr');
|
|
48
|
+
try {
|
|
49
|
+
await expect(rows.filter({ hasText: text }).first()).toBeVisible({ timeout: OVERLAY_TIMEOUT });
|
|
50
|
+
} catch (error) {
|
|
51
|
+
const firstRow = (await rows.allInnerTexts())[0]?.replace(/\s+/g, ' ').trim();
|
|
52
|
+
throw new Error(
|
|
53
|
+
`no list row shows '${text}' after reloading in '${read.language}': the list GET sent Accept-Language ` +
|
|
54
|
+
`${read.acceptLanguage ?? '(none)'} and answered ${read.status}, ` +
|
|
55
|
+
`${read.body.includes(text) ? 'WITH' : 'WITHOUT'} '${text}' in its body; the table's first row reads ` +
|
|
56
|
+
`${firstRow ? `'${firstRow}'` : '(no rows)'}`,
|
|
57
|
+
{ cause: error },
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
}
|
package/test/flows/my.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { makeApi } from '../api.js';
|
|
2
2
|
import { expect, test } from '../fixtures.js';
|
|
3
3
|
import { resolveRelationSamples } from '../form.js';
|
|
4
|
-
import { sampleRecord } from '../sample-values.js';
|
|
4
|
+
import { freshUniques, sampleRecord } from '../sample-values.js';
|
|
5
5
|
|
|
6
6
|
// Wave 2 (UI parity): the personal PAGE renders what the wire serves. Driven by the same
|
|
7
7
|
// own-row lifecycle as the wire test (created through the scoped controller, removed in the
|
|
@@ -88,6 +88,7 @@ export function myFlow(manifest, entity, opts = {}) {
|
|
|
88
88
|
}
|
|
89
89
|
// the owner FK is server-forced on the personal surface; sending a value must be pointless
|
|
90
90
|
delete payload[entity.personal.owner];
|
|
91
|
+
await freshUniques(makeApi(api, manifest), entity, payload);
|
|
91
92
|
return { payload, samples };
|
|
92
93
|
}
|
|
93
94
|
|
package/test/flows/rest.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { makeApi } from '../api.js';
|
|
2
2
|
import { expect, test } from '../fixtures.js';
|
|
3
3
|
import { resolveRelationSamples } from '../form.js';
|
|
4
|
-
import { handleField, sampleRecord } from '../sample-values.js';
|
|
4
|
+
import { freshUniques, handleField, sampleRecord } from '../sample-values.js';
|
|
5
5
|
|
|
6
6
|
// The same contract over the generated REST controllers, no browser: isolates backend
|
|
7
7
|
// failures from UI failures and verifies the manifest's field names bind.
|
|
@@ -19,6 +19,7 @@ export function restFlow(manifest, entity, opts = {}) {
|
|
|
19
19
|
for (const sample of await resolveRelationSamples(api, manifest, entity)) {
|
|
20
20
|
payload[sample.relation.name] = sample.id;
|
|
21
21
|
}
|
|
22
|
+
await freshUniques(client, entity, payload);
|
|
22
23
|
|
|
23
24
|
const created = await client.create(entity, payload);
|
|
24
25
|
const id = created?.[idProperty];
|
package/test/sample-values.js
CHANGED
|
@@ -11,6 +11,7 @@ function rand(chars, n) {
|
|
|
11
11
|
// cleanup marker and the searchable handle); short strings always contain a digit so
|
|
12
12
|
// they can never collide with letter-only nomenclature seeds (ISO codes etc.).
|
|
13
13
|
export function sampleValue(field) {
|
|
14
|
+
if (field.unique && field.type !== 'string') return uniqueValue(field);
|
|
14
15
|
switch (field.type) {
|
|
15
16
|
case 'string': {
|
|
16
17
|
const len = field.length ?? 64;
|
|
@@ -40,8 +41,10 @@ export function sampleValue(field) {
|
|
|
40
41
|
// A `pattern:` field (an authored regex, or the address regex behind `format: email`) is rejected
|
|
41
42
|
// by the generated controller with 400 unless the value has the declared shape, so the marker value
|
|
42
43
|
// above has to be traded for one that matches: the candidates are tried in order and the first that
|
|
43
|
-
// both matches and fits the declared length wins.
|
|
44
|
-
//
|
|
44
|
+
// both matches and fits the declared length wins. When nothing here matches (an IBAN-shaped regex
|
|
45
|
+
// mixing character classes by position), an optional field is left unset - the record is valid
|
|
46
|
+
// without it, and no flow needs it filled (dirigible #7525) - while a required one keeps the plain
|
|
47
|
+
// marker: the controller's own 400 then names the field and the pattern, which is the message the
|
|
45
48
|
// module author needs, and a silent near-miss would not.
|
|
46
49
|
function shaped(field, value) {
|
|
47
50
|
let regex;
|
|
@@ -59,7 +62,60 @@ function shaped(field, value) {
|
|
|
59
62
|
token, // letters and digits, no separator
|
|
60
63
|
rand(DIGITS, 10), // a numeric code
|
|
61
64
|
];
|
|
62
|
-
|
|
65
|
+
const match = candidates.find((candidate) => candidate.length <= (field.length ?? 255) && regex.test(candidate));
|
|
66
|
+
return match ?? (field.required ? value : undefined);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// A `unique:` field refuses a second row carrying its value with 409 - the module's guard doing its
|
|
70
|
+
// job. The per-type constants above are the one value certain to collide: with a seeded row, with
|
|
71
|
+
// the row a parallel flow of the same run is creating, or with one an earlier failed run left
|
|
72
|
+
// behind (dirigible #7530). So a unique field draws a fresh value per record, away from the range
|
|
73
|
+
// an author seeds (a year, a small code): an integer in the hundred-thousands, a date in the 22nd
|
|
74
|
+
// century. Strings need none of this - the marker above is already random.
|
|
75
|
+
function uniqueValue(field) {
|
|
76
|
+
const n = 100000 + Math.floor(Math.random() * 900000);
|
|
77
|
+
switch (field.type) {
|
|
78
|
+
case 'integer':
|
|
79
|
+
case 'bigint':
|
|
80
|
+
return n;
|
|
81
|
+
case 'decimal':
|
|
82
|
+
case 'double':
|
|
83
|
+
return n + 0.25;
|
|
84
|
+
case 'date':
|
|
85
|
+
case 'timestamp':
|
|
86
|
+
case 'datetime': {
|
|
87
|
+
const at = new Date(Date.UTC(2100, 0, 1) + (n % 36500) * 86400000);
|
|
88
|
+
return field.type === 'date' ? at.toISOString().slice(0, 10) : at.toISOString().replace(/\.\d{3}Z$/, 'Z');
|
|
89
|
+
}
|
|
90
|
+
default:
|
|
91
|
+
return sampleValue({ ...field, unique: false });
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// A drawn value is unlikely to collide, not certain not to - probe the live rows for each unique
|
|
96
|
+
// field of the record through the controller's filtered read and redraw on a hit, so the create the
|
|
97
|
+
// flow is about to send cannot be refused for a value that is already taken. The value goes as a
|
|
98
|
+
// string - the controller coerces it to the property's own type (an Integer would not bind to a Long
|
|
99
|
+
// or BigDecimal column). A probe that fails (an older controller without /search) keeps the value.
|
|
100
|
+
export async function freshUniques(client, entity, record) {
|
|
101
|
+
let redrawn = false;
|
|
102
|
+
for (const field of editableFields(entity)) {
|
|
103
|
+
if (!field.unique || !(field.name in record)) continue;
|
|
104
|
+
for (let attempt = 0; attempt < 5; attempt++) {
|
|
105
|
+
let rows;
|
|
106
|
+
try {
|
|
107
|
+
rows = await client.search(entity, [{ propertyName: field.name, operator: 'EQ', value: String(record[field.name]) }]);
|
|
108
|
+
} catch {
|
|
109
|
+
break;
|
|
110
|
+
}
|
|
111
|
+
if (!rows?.length) break;
|
|
112
|
+
record[field.name] = field.type === 'string' ? sampleValue(field) : uniqueValue(field);
|
|
113
|
+
redrawn = true;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
// a redrawn field may be the right-hand side of a compare check - re-derive its left operand
|
|
117
|
+
if (redrawn) steerCompares(entity, record);
|
|
118
|
+
return record;
|
|
63
119
|
}
|
|
64
120
|
|
|
65
121
|
export function editableFields(entity) {
|
|
@@ -70,17 +126,25 @@ export function editableFields(entity) {
|
|
|
70
126
|
// against live target rows).
|
|
71
127
|
export function sampleRecord(entity) {
|
|
72
128
|
const record = {};
|
|
73
|
-
for (const field of editableFields(entity))
|
|
129
|
+
for (const field of editableFields(entity)) {
|
|
130
|
+
const value = sampleValue(field);
|
|
131
|
+
if (value !== undefined) record[field.name] = value;
|
|
132
|
+
}
|
|
74
133
|
// an exactlyOne check rejects a record where more than one of the named fields is set -
|
|
75
134
|
// keep only the first of each declared set
|
|
76
135
|
for (const set of entity.exactlyOne ?? []) {
|
|
77
136
|
for (const name of set.slice(1)) delete record[name];
|
|
78
137
|
}
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
138
|
+
steerCompares(entity, record);
|
|
139
|
+
return record;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// A compare check relates the record's own field to a second value - another of its fields, or a
|
|
143
|
+
// literal - and the sample values above are per-type constants, so two dates come out EQUAL and a
|
|
144
|
+
// strict comparison (gt/lt/ne) would be rejected with 400, just as a sample quantity of 7 fails a
|
|
145
|
+
// `le 5`. Derive the left operand from whichever right-hand side the check names, by the smallest
|
|
146
|
+
// step that satisfies the declared operator (equality satisfies ge/le/eq).
|
|
147
|
+
function steerCompares(entity, record) {
|
|
84
148
|
for (const check of entity.compare ?? []) {
|
|
85
149
|
if (!(check.field in record)) continue;
|
|
86
150
|
const type = (entity.fields ?? []).find((f) => f.name === check.field)?.type;
|