@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aerokit/sdk",
3
- "version": "14.0.0",
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
@@ -0,0 +1,3 @@
1
+ export const BASE_URL = process.env.BASE_URL ?? 'http://localhost:8080';
2
+ export const USERNAME = process.env.APPTEST_USERNAME ?? 'admin';
3
+ export const PASSWORD = process.env.APPTEST_PASSWORD ?? 'admin';
@@ -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,3 @@
1
+ {
2
+ "type": "module"
3
+ }
@@ -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
+ }
@@ -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
+ }