@aerokit/sdk 14.65.0 → 14.67.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.65.0",
3
+ "version": "14.67.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,4 +1,5 @@
1
1
  import { expect, test } from '../fixtures.js';
2
+ import { makeApi } from '../api.js';
2
3
  import { fillField, fillForm, resolveRelationSamples } from '../form.js';
3
4
  import { handleField, sampleRecord } from '../sample-values.js';
4
5
 
@@ -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 ?? []);
@@ -30,55 +48,56 @@ export function crudFlow(manifest, entity, opts = {}) {
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/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
  }