@aerokit/sdk 14.66.0 → 14.68.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aerokit/sdk",
3
- "version": "14.66.0",
3
+ "version": "14.68.0",
4
4
  "description": "Unified TypeScript SDK for modern cloud platforms.",
5
5
  "scripts": {
6
6
  "clean": "rm -rf dist",
package/test/api.js CHANGED
@@ -19,6 +19,8 @@ export function makeApi(request, manifest) {
19
19
  count: (entity) => request.get(url(entity, '/count')).then(asJson).then((body) => (typeof body === 'number' ? body : body.count)),
20
20
  get: (entity, id) => request.get(url(entity, '/' + id)).then(asJson),
21
21
  getResponse: (entity, id) => request.get(url(entity, '/' + id)),
22
+ // the generated controller's filtered read (what the list's filters POST): [{ propertyName, operator, value }]
23
+ search: (entity, conditions) => request.post(url(entity, '/search'), { data: { conditions } }).then(asJson),
22
24
  create: (entity, data) => request.post(url(entity), { data }).then(asJson),
23
25
  update: (entity, id, data) => request.put(url(entity, '/' + id), { data }).then(asJson),
24
26
  // Answers whether the row is gone. A record whose process declares `whenDeleted: refuse` is
@@ -1,6 +1,7 @@
1
1
  import { expect, test } from '../fixtures.js';
2
+ import { makeApi } from '../api.js';
2
3
  import { fillField, fillForm, resolveRelationSamples } from '../form.js';
3
- import { handleField, sampleRecord } from '../sample-values.js';
4
+ import { freshUniques, handleField, sampleRecord } from '../sample-values.js';
4
5
 
5
6
  // Server-side row lookup via the toolbar "Search <Entity>..." box - present on every list
6
7
  // layout (manage-list, master-detail, document) and searching the string columns server-side.
@@ -14,6 +15,23 @@ function dataRow(page, text) {
14
15
  return page.locator('tbody tr', { hasText: text });
15
16
  }
16
17
 
18
+ // Edit and Delete of a row live in its "Row actions" menu - the one place every list layout
19
+ // (manage, document, master-detail) offers both; the manage list's record sheet puts Delete behind
20
+ // an overflow menu and its primary button may be a process task rather than Edit (#7491).
21
+ async function rowAction(page, text, action) {
22
+ await dataRow(page, text).getByRole('button', { name: 'Row actions' }).click();
23
+ await page.getByRole('menuitem', { name: action, exact: true }).click();
24
+ }
25
+
26
+ // A walk that fails halfway leaves its APPTEST- row behind, and the next run's REST flow then hits
27
+ // a unique: column with it (409). Remove whatever the walk created - the edited name starts with
28
+ // the created one - unless the UI delete already did.
29
+ async function removeLeftovers(client, manifest, entity, handle, created) {
30
+ const idProperty = manifest.idProperty ?? 'Id';
31
+ const rows = await client.search(entity, [{ propertyName: handle.name, operator: 'LIKE', value: created + '%' }]);
32
+ for (const row of rows ?? []) await client.remove(entity, row[idProperty]);
33
+ }
34
+
17
35
  export function crudFlow(manifest, entity, opts = {}) {
18
36
  const cfg = opts.extend?.entities?.[entity.name] ?? {};
19
37
  const skip = new Set(cfg.skip ?? []);
@@ -26,59 +44,60 @@ export function crudFlow(manifest, entity, opts = {}) {
26
44
  if (!handleField(entity)) return;
27
45
 
28
46
  test(`${entity.name}: create, edit and delete through the UI`, async ({ page, api }) => {
29
- const record = sampleRecord(entity);
47
+ const record = await freshUniques(makeApi(api, manifest), entity, sampleRecord(entity));
30
48
  const handle = handleField(entity);
31
49
  const relationSamples = await resolveRelationSamples(api, manifest, entity);
32
50
 
33
- // create
34
- await page.goto(manifest.standaloneShell + entity.route);
35
- // exact: the empty state adds a second "New <Entity>" button that a substring match also hits
36
- await page.getByRole('button', { name: 'New', exact: true }).click();
37
- await expect(page).toHaveURL(/\/create$/);
38
- await cfg.beforeCreate?.(page, record);
39
- await fillForm(page, manifest, entity, record, relationSamples, opts);
40
- await page.getByRole('button', { name: 'Create', exact: true }).click();
41
- if (entity.layout === 'document') {
42
- // a document create lands on the NEW record's page (line-item editing continues there);
43
- // saving an edit is what returns to the list
44
- await expect(page).toHaveURL(/\/edit$/);
51
+ const created = record[handle.name];
52
+ const listRoute = entity.route.replace(/[#/]/g, '\\$&');
53
+ try {
54
+ // create
45
55
  await page.goto(manifest.standaloneShell + entity.route);
46
- } else {
47
- await expect(page).toHaveURL(new RegExp(entity.route.replace(/[#/]/g, '\\$&') + '$'));
48
- }
49
- await filterBy(page, record[handle.name]);
50
- await expect(dataRow(page, record[handle.name])).toHaveCount(1);
51
- await cfg.afterCreate?.(page, record);
56
+ // exact: the empty state adds a second "New <Entity>" button that a substring match also hits
57
+ await page.getByRole('button', { name: 'New', exact: true }).click();
58
+ await expect(page).toHaveURL(/\/create$/);
59
+ await cfg.beforeCreate?.(page, record);
60
+ await fillForm(page, manifest, entity, record, relationSamples, opts);
61
+ await page.getByRole('button', { name: 'Create', exact: true }).click();
62
+ // A create lands on the NEW record's page when there is more to do there - a document's
63
+ // line items, a manage record's detail collections - and on the list otherwise. Saving an
64
+ // edit is what returns to the list.
65
+ const recordPage = entity.layout === 'document' ? '/[^/]+/edit' : '(/[^/]+/edit)?';
66
+ await expect(page).toHaveURL(new RegExp(listRoute + recordPage + '$'));
67
+ if (page.url().endsWith('/edit')) await page.goto(manifest.standaloneShell + entity.route);
68
+ await filterBy(page, created);
69
+ await expect(dataRow(page, created)).toHaveCount(1);
70
+ await cfg.afterCreate?.(page, record);
52
71
 
53
- // edit (via the detail pane the row selection reveals)
54
- if (!skip.has('edit')) {
55
- const updated = record[handle.name] + '-UPD';
56
- await dataRow(page, record[handle.name]).click();
57
- // exact: substring name matching would also hit e.g. a "Credit Notes" sidebar item
58
- await page.getByRole('button', { name: 'Edit', exact: true }).first().click();
59
- await expect(page).toHaveURL(/\/edit$/);
60
- // the record loads async after the form renders - filling before the fetch completes
61
- // gets overwritten by the load and Save persists the OLD value
62
- await expect(page.locator('#f_' + handle.name)).toHaveValue(record[handle.name]);
63
- await fillField(page, handle, updated, opts);
64
- await page.getByRole('button', { name: 'Save', exact: true }).click();
65
- await expect(page).toHaveURL(new RegExp(entity.route.replace(/[#/]/g, '\\$&') + '$'));
66
- await filterBy(page, updated);
67
- await expect(dataRow(page, updated)).toHaveCount(1);
68
- record[handle.name] = updated;
69
- }
72
+ // edit
73
+ if (!skip.has('edit')) {
74
+ const updated = created + '-UPD';
75
+ await rowAction(page, created, 'Edit');
76
+ await expect(page).toHaveURL(/\/edit$/);
77
+ // the record loads async after the form renders - filling before the fetch completes
78
+ // gets overwritten by the load and Save persists the OLD value
79
+ await expect(page.locator('#f_' + handle.name)).toHaveValue(created);
80
+ await fillField(page, handle, updated, opts);
81
+ await page.getByRole('button', { name: 'Save', exact: true }).click();
82
+ await expect(page).toHaveURL(new RegExp(listRoute + '$'));
83
+ await filterBy(page, updated);
84
+ await expect(dataRow(page, updated)).toHaveCount(1);
85
+ record[handle.name] = updated;
86
+ }
70
87
 
71
- // delete (detail pane trash button, then the confirm dialog). An entity whose process declares
72
- // `whenDeleted: refuse` answers the delete with an error toast while its instance runs - the
73
- // create above started it - so the walk stops here and the REST flow asserts that refusal.
74
- if (!skip.has('delete') && !entity.deleteGuardedByProcess?.length) {
75
- await dataRow(page, record[handle.name]).click();
76
- await page.getByRole('button', { name: 'Delete', exact: true }).first().click();
77
- const dialog = page.locator('[x-h-dialog-overlay][data-open]');
78
- await dialog.getByRole('button', { name: 'Delete', exact: true }).click();
79
- await expect(dialog).toHaveCount(0);
80
- await filterBy(page, record[handle.name]);
81
- await expect(dataRow(page, record[handle.name])).toHaveCount(0);
88
+ // delete (row menu, then the confirm dialog). An entity whose process declares
89
+ // `whenDeleted: refuse` answers the delete with an error toast while its instance runs - the
90
+ // create above started it - so the walk stops here and the REST flow asserts that refusal.
91
+ if (!skip.has('delete') && !entity.deleteGuardedByProcess?.length) {
92
+ await rowAction(page, record[handle.name], 'Delete');
93
+ const dialog = page.locator('[x-h-dialog-overlay][data-open]');
94
+ await dialog.getByRole('button', { name: 'Delete', exact: true }).click();
95
+ await expect(dialog).toHaveCount(0);
96
+ await filterBy(page, record[handle.name]);
97
+ await expect(dataRow(page, record[handle.name])).toHaveCount(0);
98
+ }
99
+ } finally {
100
+ await removeLeftovers(makeApi(api, manifest), manifest, entity, handle, created);
82
101
  }
83
102
  });
84
103
  }
@@ -1,14 +1,21 @@
1
1
  import { expect, test } from '../fixtures.js';
2
2
  import { labelOf } from '../sample-values.js';
3
3
 
4
- // The list page renders: plural title in the toolbar, then one of three bodies -
4
+ // The shell breadcrumb's current crumb - the one page element every list layout shares that names
5
+ // the entity (the sidebar label, which the manifest's labelPlural mirrors).
6
+ export function listCrumb(page, entity) {
7
+ return page.locator('[x-h-breadcrumb-page]', { hasText: entity.labelPlural }).first();
8
+ }
9
+
10
+ // The list page renders under the shell breadcrumb naming it by its plural label (the #7491
11
+ // manage list carries no title of its own), then one of three bodies -
5
12
  // a tree (hierarchy entities render role=treeitem nodes, no table), the table with one
6
13
  // column header per major field, or (when the entity has no rows yet) the empty state.
7
14
  // When seed data is expected, rows/nodes must actually be there.
8
15
  export function listFlow(manifest, entity) {
9
16
  test(`${entity.name}: list page renders the declared columns`, async ({ page }) => {
10
17
  await page.goto(manifest.standaloneShell + entity.route);
11
- await expect(page.locator('[x-h-toolbar-title]', { hasText: entity.labelPlural }).first()).toBeVisible();
18
+ await expect(listCrumb(page, entity)).toBeVisible();
12
19
  if (entity.hierarchy) {
13
20
  if (entity.expectSeedData) {
14
21
  await expect(page.getByRole('treeitem').first()).toBeVisible();
@@ -1,4 +1,5 @@
1
1
  import { expect, test } from '../fixtures.js';
2
+ import { listCrumb } from './list.js';
2
3
 
3
4
  // The read-time translation overlay: switch the shared language key (what the Region &
4
5
  // Language setting writes), reload, and a known seed row shows its translated name.
@@ -9,7 +10,7 @@ export function multilingualFlow(manifest, entity) {
9
10
 
10
11
  test(`${entity.name}: ${sample.language} translation overlays on read`, async ({ page }) => {
11
12
  await page.goto(manifest.standaloneShell + entity.route);
12
- await expect(page.locator('[x-h-toolbar-title]', { hasText: entity.labelPlural }).first()).toBeVisible();
13
+ await expect(listCrumb(page, entity)).toBeVisible();
13
14
  await page.evaluate((lang) => localStorage.setItem('codbex.harmonia.language', lang), sample.language);
14
15
  await page.reload();
15
16
  await expect(page.locator('tbody tr', { hasText: sample.translated }).first()).toBeVisible();
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
 
@@ -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/form.js CHANGED
@@ -16,21 +16,31 @@ export async function fillField(page, field, value, opts = {}) {
16
16
  await input.fill(String(value));
17
17
  }
18
18
 
19
- // The x-h-select directive hides its input and builds a button[role=combobox] trigger
20
- // labelled by the field label; options carry role=option.
21
- export async function pickDropdown(page, relation, optionText) {
19
+ // The x-h-select directive hides its input and appends a button[role=combobox] trigger beside it;
20
+ // options carry role=option.
21
+ // The generated manage/document forms give that input id="f_<Name>", so the trigger is reached
22
+ // through its sibling - independent of how the field label reads. The accessible name is the
23
+ // fallback for a form whose select input carries no id (the my/partner surfaces).
24
+ async function relationTrigger(page, relation) {
25
+ const input = page.locator('#f_' + relation.name);
26
+ if (await input.count()) return input.locator('xpath=../button[@role="combobox"]');
22
27
  // Anchored prefix match: the combobox accessible name is the label plus the placeholder or
23
28
  // selected value ("Country Select a Country..."), so exact matching finds nothing - while a
24
29
  // bare substring match collides with longer sibling labels ("Type" also hits "Chart Type").
25
30
  const label = relation.label ?? relation.name;
26
31
  const anchored = new RegExp('^' + label.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '\\b');
27
- await page.getByRole('combobox', { name: anchored }).first().click();
32
+ return page.getByRole('combobox', { name: anchored }).first();
33
+ }
34
+
35
+ export async function pickDropdown(page, relation, optionText) {
36
+ const trigger = await relationTrigger(page, relation);
37
+ await trigger.click();
28
38
  const option = page.getByRole('option', { name: optionText }).first();
29
39
  try {
30
40
  await option.click({ timeout: 10_000 });
31
41
  } catch {
32
42
  // the option list re-rendered mid-click (async option load reflow) - reopen and retry
33
- await page.getByRole('combobox', { name: anchored }).first().click();
43
+ await trigger.click();
34
44
  await option.click({ force: true });
35
45
  }
36
46
  }
@@ -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. A pattern nothing here matches keeps the plain
44
- // marker - the controller's own 400 then names the field and the pattern, which is the message the
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
- return candidates.find((candidate) => candidate.length <= (field.length ?? 255) && regex.test(candidate)) ?? value;
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)) record[field.name] = sampleValue(field);
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
- // a compare check relates the record's own field to a second value - another of its fields, or a
80
- // literal - and the sample values above are per-type constants, so two dates come out EQUAL and a
81
- // strict comparison (gt/lt/ne) would be rejected with 400, just as a sample quantity of 7 fails a
82
- // `le 5`. Derive the left operand from whichever right-hand side the check names, by the smallest
83
- // step that satisfies the declared operator (equality satisfies ge/le/eq).
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;