@aerokit/sdk 14.0.0 → 14.1.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/package.json +12 -1
- package/test/api.js +31 -0
- package/test/env.js +3 -0
- package/test/fixtures.js +29 -0
- package/test/flows/crud.js +82 -0
- package/test/flows/list.js +35 -0
- package/test/flows/multilingual.js +20 -0
- package/test/flows/rest.js +44 -0
- package/test/flows/shell.js +16 -0
- package/test/form.js +120 -0
- package/test/index.js +26 -0
- package/test/package.json +3 -0
- package/test/sample-values.js +67 -0
- package/test/session.js +19 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aerokit/sdk",
|
|
3
|
-
"version": "14.
|
|
3
|
+
"version": "14.1.1",
|
|
4
4
|
"description": "Unified TypeScript SDK for modern cloud platforms.",
|
|
5
5
|
"scripts": {
|
|
6
6
|
"clean": "rm -rf dist",
|
|
@@ -29,6 +29,7 @@
|
|
|
29
29
|
},
|
|
30
30
|
"files": [
|
|
31
31
|
"dist",
|
|
32
|
+
"test",
|
|
32
33
|
"LICENSE",
|
|
33
34
|
"README.md",
|
|
34
35
|
"package.json"
|
|
@@ -42,6 +43,8 @@
|
|
|
42
43
|
"require": "./dist/cjs/index.js",
|
|
43
44
|
"types": "./dist/dts/index.d.ts"
|
|
44
45
|
},
|
|
46
|
+
"./test": "./test/index.js",
|
|
47
|
+
"./test/fixtures": "./test/fixtures.js",
|
|
45
48
|
"./*": {
|
|
46
49
|
"import": "./dist/esm/*/index.mjs",
|
|
47
50
|
"require": "./dist/cjs/*/index.js",
|
|
@@ -54,5 +57,13 @@
|
|
|
54
57
|
"dist/dts/*/index.d.ts"
|
|
55
58
|
]
|
|
56
59
|
}
|
|
60
|
+
},
|
|
61
|
+
"peerDependencies": {
|
|
62
|
+
"@playwright/test": ">=1.45"
|
|
63
|
+
},
|
|
64
|
+
"peerDependenciesMeta": {
|
|
65
|
+
"@playwright/test": {
|
|
66
|
+
"optional": true
|
|
67
|
+
}
|
|
57
68
|
}
|
|
58
69
|
}
|
package/test/api.js
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// Thin REST client over the generated Java controllers described by the manifest.
|
|
2
|
+
export function makeApi(request, manifest) {
|
|
3
|
+
const url = (entity, suffix = '') => manifest.restBase + entity.api + suffix;
|
|
4
|
+
|
|
5
|
+
async function asJson(response) {
|
|
6
|
+
if (!response.ok()) {
|
|
7
|
+
// APIResponse has no request(); report url + status + body
|
|
8
|
+
throw new Error(`${response.url()} -> ${response.status()} ${await response.text()}`);
|
|
9
|
+
}
|
|
10
|
+
const text = await response.text();
|
|
11
|
+
return text ? JSON.parse(text) : undefined;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
return {
|
|
15
|
+
list: (entity, limit = 20) => request.get(url(entity, `?$limit=${limit}`)).then(asJson),
|
|
16
|
+
// absolute controller path (a cross-model relation target owned by another module)
|
|
17
|
+
listPath: (path, limit = 20) => request.get(`${path}?$limit=${limit}`).then(asJson),
|
|
18
|
+
getPath: (path) => request.get(path).then(asJson),
|
|
19
|
+
count: (entity) => request.get(url(entity, '/count')).then(asJson).then((body) => (typeof body === 'number' ? body : body.count)),
|
|
20
|
+
get: (entity, id) => request.get(url(entity, '/' + id)).then(asJson),
|
|
21
|
+
getResponse: (entity, id) => request.get(url(entity, '/' + id)),
|
|
22
|
+
create: (entity, data) => request.post(url(entity), { data }).then(asJson),
|
|
23
|
+
update: (entity, id, data) => request.put(url(entity, '/' + id), { data }).then(asJson),
|
|
24
|
+
remove: async (entity, id) => {
|
|
25
|
+
const response = await request.delete(url(entity, '/' + id));
|
|
26
|
+
if (!response.ok()) {
|
|
27
|
+
throw new Error(`DELETE ${response.url()} -> ${response.status()} ${await response.text()}`);
|
|
28
|
+
}
|
|
29
|
+
},
|
|
30
|
+
};
|
|
31
|
+
}
|
package/test/env.js
ADDED
package/test/fixtures.js
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { expect, test as base } from '@playwright/test';
|
|
2
|
+
import { BASE_URL } from './env.js';
|
|
3
|
+
import { login } from './session.js';
|
|
4
|
+
|
|
5
|
+
// Pre-authenticated Playwright test:
|
|
6
|
+
// - authState: worker-scoped form login, one session per worker
|
|
7
|
+
// - storageState: every page starts logged in
|
|
8
|
+
// - api: an APIRequestContext carrying the same session, for REST-level checks
|
|
9
|
+
export const test = base.extend({
|
|
10
|
+
authState: [
|
|
11
|
+
async ({ browser }, use) => {
|
|
12
|
+
await use(await login(browser));
|
|
13
|
+
},
|
|
14
|
+
{ scope: 'worker' },
|
|
15
|
+
],
|
|
16
|
+
storageState: async ({ authState }, use) => {
|
|
17
|
+
await use(authState);
|
|
18
|
+
},
|
|
19
|
+
api: async ({ playwright, authState }, use) => {
|
|
20
|
+
const request = await playwright.request.newContext({
|
|
21
|
+
baseURL: BASE_URL,
|
|
22
|
+
storageState: authState,
|
|
23
|
+
});
|
|
24
|
+
await use(request);
|
|
25
|
+
await request.dispose();
|
|
26
|
+
},
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
export { expect };
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { expect, test } from '../fixtures.js';
|
|
2
|
+
import { fillField, fillForm, resolveRelationSamples } from '../form.js';
|
|
3
|
+
import { handleField, sampleRecord } from '../sample-values.js';
|
|
4
|
+
|
|
5
|
+
// Server-side row lookup via the toolbar "Search <Entity>..." box - present on every list
|
|
6
|
+
// layout (manage-list, master-detail, document) and searching the string columns server-side.
|
|
7
|
+
// The per-column filter row is NOT used: its first input can belong to an FK or date column
|
|
8
|
+
// (hidden or non-text), which made a blind fill hang.
|
|
9
|
+
async function filterBy(page, value) {
|
|
10
|
+
await page.getByPlaceholder(/^Search /).first().fill(value);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function dataRow(page, text) {
|
|
14
|
+
return page.locator('tbody tr', { hasText: text });
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function crudFlow(manifest, entity, opts = {}) {
|
|
18
|
+
const cfg = opts.extend?.entities?.[entity.name] ?? {};
|
|
19
|
+
const skip = new Set(cfg.skip ?? []);
|
|
20
|
+
if (skip.has('crud')) return;
|
|
21
|
+
// A hierarchy entity lists as a tree, and a calendar/slots entity replaces the table page
|
|
22
|
+
// entirely - no filter row / data rows to drive the walk below; create/read/update/delete
|
|
23
|
+
// stays covered by the REST flow. Same for an entity without a string handle field (nothing
|
|
24
|
+
// searchable identifies the created row in the table).
|
|
25
|
+
if (entity.hierarchy || entity.layout === 'calendar' || entity.layout === 'slots') return;
|
|
26
|
+
if (!handleField(entity)) return;
|
|
27
|
+
|
|
28
|
+
test(`${entity.name}: create, edit and delete through the UI`, async ({ page, api }) => {
|
|
29
|
+
const record = sampleRecord(entity);
|
|
30
|
+
const handle = handleField(entity);
|
|
31
|
+
const relationSamples = await resolveRelationSamples(api, manifest, entity);
|
|
32
|
+
|
|
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$/);
|
|
45
|
+
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);
|
|
52
|
+
|
|
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
|
+
}
|
|
70
|
+
|
|
71
|
+
// delete (detail pane trash button, then the confirm dialog)
|
|
72
|
+
if (!skip.has('delete')) {
|
|
73
|
+
await dataRow(page, record[handle.name]).click();
|
|
74
|
+
await page.getByRole('button', { name: 'Delete', exact: true }).first().click();
|
|
75
|
+
const dialog = page.locator('[x-h-dialog-overlay][data-open]');
|
|
76
|
+
await dialog.getByRole('button', { name: 'Delete', exact: true }).click();
|
|
77
|
+
await expect(dialog).toHaveCount(0);
|
|
78
|
+
await filterBy(page, record[handle.name]);
|
|
79
|
+
await expect(dataRow(page, record[handle.name])).toHaveCount(0);
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { expect, test } from '../fixtures.js';
|
|
2
|
+
import { labelOf } from '../sample-values.js';
|
|
3
|
+
|
|
4
|
+
// The list page renders: plural title in the toolbar, then one of three bodies -
|
|
5
|
+
// a tree (hierarchy entities render role=treeitem nodes, no table), the table with one
|
|
6
|
+
// column header per major field, or (when the entity has no rows yet) the empty state.
|
|
7
|
+
// When seed data is expected, rows/nodes must actually be there.
|
|
8
|
+
export function listFlow(manifest, entity) {
|
|
9
|
+
test(`${entity.name}: list page renders the declared columns`, async ({ page }) => {
|
|
10
|
+
await page.goto(manifest.standaloneShell + entity.route);
|
|
11
|
+
await expect(page.locator('[x-h-toolbar-title]', { hasText: entity.labelPlural }).first()).toBeVisible();
|
|
12
|
+
if (entity.hierarchy) {
|
|
13
|
+
if (entity.expectSeedData) {
|
|
14
|
+
await expect(page.getByRole('treeitem').first()).toBeVisible();
|
|
15
|
+
}
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
18
|
+
if (entity.layout === 'calendar' || entity.layout === 'slots') {
|
|
19
|
+
// the view family renders a calendar / slot picker instead of the table
|
|
20
|
+
await expect(page.locator('[x-h-calendar], [x-h-slot-picker]').first()).toBeVisible();
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
// filter({ visible: true }): the empty-state markup stays in the DOM (x-show) above the
|
|
24
|
+
// table, so an unfiltered union's .first() would pick the hidden element and always fail
|
|
25
|
+
const firstHeader = page.getByRole('columnheader').filter({ visible: true }).first();
|
|
26
|
+
const emptyState = page.getByText('Get started by creating the first record').filter({ visible: true }).first();
|
|
27
|
+
await expect(firstHeader.or(emptyState).first()).toBeVisible();
|
|
28
|
+
if (entity.expectSeedData || (await firstHeader.isVisible())) {
|
|
29
|
+
for (const field of (entity.fields ?? []).filter((f) => f.major !== false && !f.primaryKey)) {
|
|
30
|
+
await expect(page.getByRole('columnheader').filter({ hasText: labelOf(field) }).first()).toBeVisible();
|
|
31
|
+
}
|
|
32
|
+
await expect(page.locator('tbody tr:visible').first()).toBeVisible();
|
|
33
|
+
}
|
|
34
|
+
});
|
|
35
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { expect, test } from '../fixtures.js';
|
|
2
|
+
|
|
3
|
+
// The read-time translation overlay: switch the shared language key (what the Region &
|
|
4
|
+
// Language setting writes), reload, and a known seed row shows its translated name.
|
|
5
|
+
// Needs a concrete sample in the manifest: { language, base, translated }.
|
|
6
|
+
export function multilingualFlow(manifest, entity) {
|
|
7
|
+
const sample = entity.multilingualSample;
|
|
8
|
+
if (!entity.multilingual || !sample) return;
|
|
9
|
+
|
|
10
|
+
test(`${entity.name}: ${sample.language} translation overlays on read`, async ({ page }) => {
|
|
11
|
+
await page.goto(manifest.standaloneShell + entity.route);
|
|
12
|
+
await expect(page.locator('[x-h-toolbar-title]', { hasText: entity.labelPlural }).first()).toBeVisible();
|
|
13
|
+
await page.evaluate((lang) => localStorage.setItem('codbex.harmonia.language', lang), sample.language);
|
|
14
|
+
await page.reload();
|
|
15
|
+
await expect(page.locator('tbody tr', { hasText: sample.translated }).first()).toBeVisible();
|
|
16
|
+
await page.evaluate(() => localStorage.setItem('codbex.harmonia.language', 'en'));
|
|
17
|
+
await page.reload();
|
|
18
|
+
await expect(page.locator('tbody tr', { hasText: sample.base }).first()).toBeVisible();
|
|
19
|
+
});
|
|
20
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { makeApi } from '../api.js';
|
|
2
|
+
import { expect, test } from '../fixtures.js';
|
|
3
|
+
import { resolveRelationSamples } from '../form.js';
|
|
4
|
+
import { handleField, sampleRecord } from '../sample-values.js';
|
|
5
|
+
|
|
6
|
+
// The same contract over the generated REST controllers, no browser: isolates backend
|
|
7
|
+
// failures from UI failures and verifies the manifest's field names bind.
|
|
8
|
+
export function restFlow(manifest, entity, opts = {}) {
|
|
9
|
+
const cfg = opts.extend?.entities?.[entity.name] ?? {};
|
|
10
|
+
if (new Set(cfg.skip ?? []).has('rest')) return;
|
|
11
|
+
const idProperty = manifest.idProperty ?? 'Id';
|
|
12
|
+
|
|
13
|
+
test(`${entity.name}: REST create, read, update and delete`, async ({ api }) => {
|
|
14
|
+
const client = makeApi(api, manifest);
|
|
15
|
+
const payload = sampleRecord(entity);
|
|
16
|
+
const handle = handleField(entity);
|
|
17
|
+
// same resolution the UI flow uses: cross-model targets via apiAbsolute, entityStatus
|
|
18
|
+
// relations left to their init: DB default
|
|
19
|
+
for (const sample of await resolveRelationSamples(api, manifest, entity)) {
|
|
20
|
+
payload[sample.relation.name] = sample.id;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const created = await client.create(entity, payload);
|
|
24
|
+
const id = created?.[idProperty];
|
|
25
|
+
expect(id, 'create response carries the generated id').toBeTruthy();
|
|
26
|
+
try {
|
|
27
|
+
const read = await client.get(entity, id);
|
|
28
|
+
expect(read[idProperty]).toBe(id);
|
|
29
|
+
// the update round-trip needs a writable string field to flip; skip it when there is none
|
|
30
|
+
if (handle) {
|
|
31
|
+
expect(read[handle.name]).toBe(payload[handle.name]);
|
|
32
|
+
|
|
33
|
+
const updatedValue = payload[handle.name] + '-UPD';
|
|
34
|
+
await client.update(entity, id, { ...read, [handle.name]: updatedValue });
|
|
35
|
+
const reread = await client.get(entity, id);
|
|
36
|
+
expect(reread[handle.name]).toBe(updatedValue);
|
|
37
|
+
}
|
|
38
|
+
} finally {
|
|
39
|
+
await client.remove(entity, id);
|
|
40
|
+
}
|
|
41
|
+
const gone = await client.getResponse(entity, id);
|
|
42
|
+
expect(gone.status()).toBe(404);
|
|
43
|
+
});
|
|
44
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { expect, test } from '../fixtures.js';
|
|
2
|
+
|
|
3
|
+
// Shared application shell (/services/web/application/): the entity's menu item exists
|
|
4
|
+
// under its nav group and opens the module SPA in the embedded iframe. Requires the
|
|
5
|
+
// navigation module that defines the groups, so it is opt-in: APPTEST_SHELL=1.
|
|
6
|
+
export function shellFlow(manifest, entity) {
|
|
7
|
+
test(`${entity.name}: shared shell menu item opens the module`, async ({ page }) => {
|
|
8
|
+
test.skip(!process.env.APPTEST_SHELL, 'APPTEST_SHELL=1 enables the shared shell flow');
|
|
9
|
+
await page.goto('/services/web/application/');
|
|
10
|
+
const item = page.getByText(entity.labelPlural, { exact: true }).first();
|
|
11
|
+
await expect(item).toBeVisible();
|
|
12
|
+
await item.click();
|
|
13
|
+
const app = page.frameLocator('iframe').first();
|
|
14
|
+
await expect(app.getByText(entity.labelPlural, { exact: true }).first()).toBeVisible();
|
|
15
|
+
});
|
|
16
|
+
}
|
package/test/form.js
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { makeApi } from './api.js';
|
|
2
|
+
import { editableFields, handleField } from './sample-values.js';
|
|
3
|
+
|
|
4
|
+
// Generated form controls: every field input has id="f_<Name>"; a to-one relation is a
|
|
5
|
+
// Harmonia x-h-select whose input holds the value and whose options carry the label.
|
|
6
|
+
export async function fillField(page, field, value, opts = {}) {
|
|
7
|
+
const custom = opts.extend?.widgets?.[field.widget];
|
|
8
|
+
if (custom) return custom(page, field, value);
|
|
9
|
+
const input = page.locator('#f_' + field.name);
|
|
10
|
+
if (field.type === 'boolean') return input.setChecked(!!value);
|
|
11
|
+
if (field.type === 'timestamp' || field.type === 'datetime') {
|
|
12
|
+
// the sample is a full ISO instant (what the REST layer binds); a datetime-local input
|
|
13
|
+
// takes the zone-less YYYY-MM-DDTHH:mm prefix
|
|
14
|
+
return input.fill(String(value).slice(0, 16));
|
|
15
|
+
}
|
|
16
|
+
await input.fill(String(value));
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// The x-h-select directive hides its input and builds a span[role=combobox] trigger
|
|
20
|
+
// labelled by the field label; options carry role=option.
|
|
21
|
+
export async function pickDropdown(page, relation, optionText) {
|
|
22
|
+
// Anchored prefix match: the combobox accessible name is the label plus the placeholder or
|
|
23
|
+
// selected value ("Country Select a Country..."), so exact matching finds nothing - while a
|
|
24
|
+
// bare substring match collides with longer sibling labels ("Type" also hits "Chart Type").
|
|
25
|
+
const label = relation.label ?? relation.name;
|
|
26
|
+
const anchored = new RegExp('^' + label.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '\\b');
|
|
27
|
+
await page.getByRole('combobox', { name: anchored }).first().click();
|
|
28
|
+
const option = page.getByRole('option', { name: optionText }).first();
|
|
29
|
+
try {
|
|
30
|
+
await option.click({ timeout: 10_000 });
|
|
31
|
+
} catch {
|
|
32
|
+
// the option list re-rendered mid-click (async option load reflow) - reopen and retry
|
|
33
|
+
await page.getByRole('combobox', { name: anchored }).first().click();
|
|
34
|
+
await option.click({ force: true });
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Resolve a live option for each to-one relation: take the first suitable row of the
|
|
39
|
+
// target entity and use its label field's value as the visible option text.
|
|
40
|
+
// - an entityStatus relation is skipped: it renders as a status pill (not an editable
|
|
41
|
+
// input in any form) and its value comes from the init: DB default;
|
|
42
|
+
// - a cross-model relation's rows come from its apiAbsolute controller URL (the target
|
|
43
|
+
// lives in another module and is not in this manifest);
|
|
44
|
+
// - a where: option filter narrows the candidate rows to matching ones;
|
|
45
|
+
// - a dependsOn cascade forces CONSISTENT samples: the dependent row is chosen first and
|
|
46
|
+
// its filterBy FK becomes the trigger sibling's sample (independent first rows would
|
|
47
|
+
// pick e.g. Country=Afghanistan + City=Sofia, and the cascade then offers no options).
|
|
48
|
+
export async function resolveRelationSamples(request, manifest, entity) {
|
|
49
|
+
const api = makeApi(request, manifest);
|
|
50
|
+
const idProperty = manifest.idProperty ?? 'Id';
|
|
51
|
+
|
|
52
|
+
async function fetchRows(relation, limit) {
|
|
53
|
+
if (relation.apiAbsolute) return { rows: await api.listPath(relation.apiAbsolute, limit), labelFrom: relation.labelFrom ?? 'Name' };
|
|
54
|
+
if (relation.api) return { rows: await api.listPath(manifest.restBase + relation.api, limit), labelFrom: relation.labelFrom ?? 'Name' };
|
|
55
|
+
const target = manifest.entities.find((e) => e.name === relation.to);
|
|
56
|
+
if (!target) throw new Error(`Relation ${entity.name}.${relation.name}: target ${relation.to} not in manifest`);
|
|
57
|
+
return { rows: await api.list(target, limit), labelFrom: relation.labelFrom ?? handleField(target)?.name ?? 'Name' };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const samples = [];
|
|
61
|
+
for (const relation of entity.relations ?? []) {
|
|
62
|
+
if (relation.entityStatus) continue;
|
|
63
|
+
// a filtered picker needs a matching candidate, so fetch a page and filter client-side
|
|
64
|
+
const wide = relation.where || relation.leafOnly;
|
|
65
|
+
const { rows: fetched, labelFrom } = await fetchRows(relation, wide ? 200 : 1);
|
|
66
|
+
let rows = relation.where ? fetched?.filter((r) => String(r[relation.where.by]) === String(relation.where.value)) : fetched;
|
|
67
|
+
if (relation.leafOnly && rows?.length) {
|
|
68
|
+
// the generated validation rejects a non-leaf target - pick a row no other row parents
|
|
69
|
+
const prop = relation.leafOnly.hierarchyProperty;
|
|
70
|
+
const idProp = manifest.idProperty ?? 'Id';
|
|
71
|
+
rows = rows.filter((row) => !fetched.some((other) => other[prop] === row[idProp]));
|
|
72
|
+
}
|
|
73
|
+
if (!rows?.length) {
|
|
74
|
+
// a required FK cannot be satisfied - fail loudly; an optional one is simply left unset
|
|
75
|
+
if (relation.required) throw new Error(`Relation ${entity.name}.${relation.name}: no ${relation.to} rows to pick from`);
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
const label = rows[0][labelFrom];
|
|
79
|
+
if (label == null && !relation.required) {
|
|
80
|
+
// no display label to pick by (the target has no name-like field) - leave the optional
|
|
81
|
+
// relation unset rather than clicking blind
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
samples.push({
|
|
85
|
+
relation,
|
|
86
|
+
row: rows[0],
|
|
87
|
+
id: rows[0][idProperty],
|
|
88
|
+
label: label ?? String(rows[0][idProperty]),
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// cascade consistency: re-point each dependsOn trigger at the row the dependent's choice implies
|
|
93
|
+
for (const sample of samples) {
|
|
94
|
+
const dependsOn = sample.relation.dependsOn;
|
|
95
|
+
if (!dependsOn?.filterBy) continue;
|
|
96
|
+
const trigger = samples.find((s) => s.relation.name === dependsOn.relation);
|
|
97
|
+
const impliedId = sample.row[dependsOn.filterBy];
|
|
98
|
+
if (!trigger || impliedId == null || trigger.id === impliedId) continue;
|
|
99
|
+
const path = trigger.relation.apiAbsolute ?? (trigger.relation.api ? manifest.restBase + trigger.relation.api : null);
|
|
100
|
+
if (!path) continue;
|
|
101
|
+
const row = await api.getPath(`${path}/${impliedId}`);
|
|
102
|
+
if (!row) continue;
|
|
103
|
+
trigger.row = row;
|
|
104
|
+
trigger.id = impliedId;
|
|
105
|
+
trigger.label = row[trigger.relation.labelFrom ?? 'Name'];
|
|
106
|
+
}
|
|
107
|
+
return samples;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export async function fillForm(page, manifest, entity, record, relationSamples, opts = {}) {
|
|
111
|
+
for (const field of editableFields(entity)) {
|
|
112
|
+
if (record[field.name] === undefined) continue;
|
|
113
|
+
await fillField(page, field, record[field.name], opts);
|
|
114
|
+
}
|
|
115
|
+
// cascade order: a dependsOn trigger must be picked BEFORE its dependent, so the narrowed
|
|
116
|
+
// option list is the one the dependent's sample was chosen from
|
|
117
|
+
const triggers = relationSamples.filter((s) => !s.relation.dependsOn);
|
|
118
|
+
const dependents = relationSamples.filter((s) => s.relation.dependsOn);
|
|
119
|
+
for (const sample of [...triggers, ...dependents]) await pickDropdown(page, sample.relation, sample.label);
|
|
120
|
+
}
|
package/test/index.js
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import { test } from './fixtures.js';
|
|
3
|
+
import { crudFlow } from './flows/crud.js';
|
|
4
|
+
import { listFlow } from './flows/list.js';
|
|
5
|
+
import { multilingualFlow } from './flows/multilingual.js';
|
|
6
|
+
import { restFlow } from './flows/rest.js';
|
|
7
|
+
import { shellFlow } from './flows/shell.js';
|
|
8
|
+
|
|
9
|
+
// Entry point: execute a module's generated <name>.test manifest. Accepts the parsed
|
|
10
|
+
// manifest object or a path to the file. opts.extend hosts the custom-UI hooks (widget
|
|
11
|
+
// fillers, per-entity skip lists, before/afterCreate).
|
|
12
|
+
export function runTest(manifestRef, opts = {}) {
|
|
13
|
+
const manifest = typeof manifestRef === 'string' ? JSON.parse(fs.readFileSync(manifestRef, 'utf8')) : manifestRef;
|
|
14
|
+
for (const entity of manifest.entities ?? []) {
|
|
15
|
+
test.describe(`${manifest.module} / ${entity.name}`, () => {
|
|
16
|
+
listFlow(manifest, entity, opts);
|
|
17
|
+
crudFlow(manifest, entity, opts);
|
|
18
|
+
restFlow(manifest, entity, opts);
|
|
19
|
+
multilingualFlow(manifest, entity, opts);
|
|
20
|
+
shellFlow(manifest, entity, opts);
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Deprecated alias - the pre-subpath name; use runTest.
|
|
26
|
+
export const runAppTest = runTest;
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
const ALPHA = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
|
|
2
|
+
const DIGITS = '0123456789';
|
|
3
|
+
|
|
4
|
+
function rand(chars, n) {
|
|
5
|
+
let out = '';
|
|
6
|
+
for (let i = 0; i < n; i++) out += chars[Math.floor(Math.random() * chars.length)];
|
|
7
|
+
return out;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
// Type-aware sample value for one field. Long strings carry the APPTEST- prefix (the
|
|
11
|
+
// cleanup marker and the searchable handle); short strings always contain a digit so
|
|
12
|
+
// they can never collide with letter-only nomenclature seeds (ISO codes etc.).
|
|
13
|
+
export function sampleValue(field) {
|
|
14
|
+
switch (field.type) {
|
|
15
|
+
case 'string': {
|
|
16
|
+
const len = field.length ?? 64;
|
|
17
|
+
if (len >= 16) return 'APPTEST-' + rand(ALPHA + DIGITS, 6);
|
|
18
|
+
return rand(ALPHA, Math.max(1, len - 1)) + rand(DIGITS, 1);
|
|
19
|
+
}
|
|
20
|
+
case 'integer':
|
|
21
|
+
case 'bigint':
|
|
22
|
+
return 7;
|
|
23
|
+
case 'decimal':
|
|
24
|
+
case 'double':
|
|
25
|
+
return 3.14;
|
|
26
|
+
case 'boolean':
|
|
27
|
+
return true;
|
|
28
|
+
case 'date':
|
|
29
|
+
return '2026-07-08';
|
|
30
|
+
case 'timestamp':
|
|
31
|
+
case 'datetime':
|
|
32
|
+
// full ISO instant: the generated Java entities bind java.time.Instant, which rejects a
|
|
33
|
+
// zone-less value; the UI fill slices this to the datetime-local shape
|
|
34
|
+
return '2026-07-08T10:00:00Z';
|
|
35
|
+
default:
|
|
36
|
+
return 'APPTEST-' + rand(ALPHA + DIGITS, 6);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function editableFields(entity) {
|
|
41
|
+
return (entity.fields ?? []).filter((f) => !f.readOnly && !f.primaryKey && !f.generated);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// One sample record for the entity's own fields (relations are resolved separately,
|
|
45
|
+
// against live target rows).
|
|
46
|
+
export function sampleRecord(entity) {
|
|
47
|
+
const record = {};
|
|
48
|
+
for (const field of editableFields(entity)) record[field.name] = sampleValue(field);
|
|
49
|
+
// an exactlyOne check rejects a record where more than one of the named fields is set -
|
|
50
|
+
// keep only the first of each declared set
|
|
51
|
+
for (const set of entity.exactlyOne ?? []) {
|
|
52
|
+
for (const name of set.slice(1)) delete record[name];
|
|
53
|
+
}
|
|
54
|
+
return record;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// The searchable "handle" field: the first long string field shown in the list. Its
|
|
58
|
+
// value identifies the record in the table across the create/edit/delete flow.
|
|
59
|
+
// Null when the entity has no such field (all-numeric/date entities) - flows degrade:
|
|
60
|
+
// the UI walk is skipped and the REST flow drops its update-value assertion.
|
|
61
|
+
export function handleField(entity) {
|
|
62
|
+
return editableFields(entity).find((f) => f.type === 'string' && (f.length ?? 64) >= 16 && f.major !== false) ?? null;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function labelOf(field) {
|
|
66
|
+
return field.label ?? field.name;
|
|
67
|
+
}
|
package/test/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
|
+
}
|