@aerokit/test 0.0.0-stage → 15.0.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.
@@ -0,0 +1,121 @@
1
+ import { expect, test } from '../fixtures.js';
2
+ import { makeApi } from '../api.js';
3
+ import { fillField, fillForm, resolveRelationSamples } from '../form.js';
4
+ import { freshUniqueKeys, freshUniques, handleField, sampleRecord } from '../sample-values.js';
5
+
6
+ // Server-side row lookup via the toolbar "Search <Entity>..." box - present on every list
7
+ // layout (manage-list, master-detail, document) and searching the string columns server-side.
8
+ // The per-column filter row is NOT used: its first input can belong to an FK or date column
9
+ // (hidden or non-text), which made a blind fill hang.
10
+ async function filterBy(page, value) {
11
+ await page.getByPlaceholder(/^Search /).first().fill(value);
12
+ }
13
+
14
+ function dataRow(page, text) {
15
+ return page.locator('tbody tr', { hasText: text });
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
+
35
+ export function crudFlow(manifest, entity, opts = {}) {
36
+ const cfg = opts.extend?.entities?.[entity.name] ?? {};
37
+ const skip = new Set(cfg.skip ?? []);
38
+ if (skip.has('crud')) return;
39
+ // no power page to walk: a composition child is reached through its parent (dirigible #7545)
40
+ if (!entity.route) return;
41
+ // A hierarchy entity lists as a tree, and a calendar/slots entity replaces the table page
42
+ // entirely - no filter row / data rows to drive the walk below; create/read/update/delete
43
+ // stays covered by the REST flow. Same for an entity without a string handle field (nothing
44
+ // searchable identifies the created row in the table).
45
+ if (entity.hierarchy || entity.layout === 'calendar' || entity.layout === 'slots') return;
46
+ if (!handleField(entity)) return;
47
+
48
+ test(`${entity.name}: create, edit and delete through the UI`, async ({ page, api }) => {
49
+ const record = await freshUniques(makeApi(api, manifest), entity, sampleRecord(entity));
50
+ const handle = handleField(entity);
51
+ const relationSamples = await resolveRelationSamples(api, manifest, entity);
52
+ const taken = await freshUniqueKeys(makeApi(api, manifest), entity, record, relationSamples);
53
+ test.skip(!!taken, `every ${entity.name} combination of the unique key [${taken}] tried already has a row`);
54
+
55
+ const created = record[handle.name];
56
+ const listRoute = entity.route.replace(/[#/]/g, '\\$&');
57
+ try {
58
+ // create
59
+ await page.goto(manifest.standaloneShell + entity.route);
60
+ // exact: the empty state adds a second "New <Entity>" button that a substring match also hits
61
+ await page.getByRole('button', { name: 'New', exact: true }).click();
62
+ await expect(page).toHaveURL(/\/create$/);
63
+ await cfg.beforeCreate?.(page, record);
64
+ await fillForm(page, manifest, entity, record, relationSamples, opts);
65
+ await page.getByRole('button', { name: 'Create', exact: true }).click();
66
+ // A create lands on the NEW record's page when there is more to do there - a document's
67
+ // line items, a manage record's detail collections - and on the list otherwise. Saving an
68
+ // edit returns to the list, except on a document, whose Save stays on it (#7359).
69
+ const recordPage = entity.layout === 'document' ? '/[^/]+/edit' : '(/[^/]+/edit)?';
70
+ await expect(page).toHaveURL(new RegExp(listRoute + recordPage + '$'));
71
+ if (page.url().endsWith('/edit')) await page.goto(manifest.standaloneShell + entity.route);
72
+ await filterBy(page, created);
73
+ await expect(dataRow(page, created)).toHaveCount(1);
74
+ await cfg.afterCreate?.(page, record);
75
+
76
+ // edit
77
+ if (!skip.has('edit')) {
78
+ const updated = created + '-UPD';
79
+ await rowAction(page, created, 'Edit');
80
+ await expect(page).toHaveURL(/\/edit$/);
81
+ // the record loads async after the form renders - filling before the fetch completes
82
+ // gets overwritten by the load and Save persists the OLD value
83
+ await expect(page.locator('#f_' + handle.name)).toHaveValue(created);
84
+ await fillField(page, handle, updated, opts);
85
+ if (entity.layout === 'document') {
86
+ // A document Save STAYS on the document and re-reads it (#7359): wait for the update to
87
+ // land, then leave for the list the way a user does (dirigible #7545).
88
+ const save = page.getByRole('button', { name: 'Save', exact: true });
89
+ const saved = page.waitForResponse((response) => response.request().method() === 'PUT' && response.ok());
90
+ await save.click();
91
+ await saved;
92
+ // Save is disabled again once the re-read header is the new pristine state - leaving before
93
+ // that would meet the unsaved-changes guard
94
+ await expect(save).toBeDisabled();
95
+ // A clean document's dismiss is Cancel (dirigible #7770).
96
+ await page.getByRole('button', { name: 'Cancel', exact: true }).click();
97
+ } else {
98
+ await page.getByRole('button', { name: 'Save', exact: true }).click();
99
+ }
100
+ await expect(page).toHaveURL(new RegExp(listRoute + '$'));
101
+ await filterBy(page, updated);
102
+ await expect(dataRow(page, updated)).toHaveCount(1);
103
+ record[handle.name] = updated;
104
+ }
105
+
106
+ // delete (row menu, then the confirm dialog). An entity whose process declares
107
+ // `whenDeleted: refuse` answers the delete with an error toast while its instance runs - the
108
+ // create above started it - so the walk stops here and the REST flow asserts that refusal.
109
+ if (!skip.has('delete') && !entity.deleteGuardedByProcess?.length) {
110
+ await rowAction(page, record[handle.name], 'Delete');
111
+ const dialog = page.locator('[x-h-dialog-overlay][data-open]');
112
+ await dialog.getByRole('button', { name: 'Delete', exact: true }).click();
113
+ await expect(dialog).toHaveCount(0);
114
+ await filterBy(page, record[handle.name]);
115
+ await expect(dataRow(page, record[handle.name])).toHaveCount(0);
116
+ }
117
+ } finally {
118
+ await removeLeftovers(makeApi(api, manifest), manifest, entity, handle, created);
119
+ }
120
+ });
121
+ }
@@ -0,0 +1,82 @@
1
+ import { expect, test } from '../fixtures.js';
2
+ import { labelOf } from '../sample-values.js';
3
+
4
+ // The calendar / slot-picker view root. The slots page renders `x-h-slot-picker.responsive`, and an
5
+ // attribute selector matches an attribute NAME exactly, so the modifier form is listed with its dot
6
+ // escaped (dirigible #7545).
7
+ export const CALENDAR_OR_SLOTS = '[x-h-calendar], [x-h-slot-picker], [x-h-slot-picker\\.responsive]';
8
+
9
+ // The shell breadcrumb's current crumb - the one page element every list layout shares that names
10
+ // the entity (the sidebar label, which the manifest's labelPlural mirrors).
11
+ export function listCrumb(page, entity) {
12
+ return page.locator('[x-h-breadcrumb-page]', { hasText: entity.labelPlural }).first();
13
+ }
14
+
15
+ // The list page renders under the shell breadcrumb naming it by its plural label (the #7491
16
+ // manage list carries no title of its own), then one of three bodies -
17
+ // a tree (hierarchy entities render role=treeitem nodes, no table), the table with one
18
+ // column header per major field, or (when the entity has no rows yet) the empty state.
19
+ // When seed data is expected, rows/nodes must actually be there.
20
+ export function listFlow(manifest, entity) {
21
+ // no power page: a composition child is reached through its parent (dirigible #7545)
22
+ if (!entity.route) return;
23
+ test(`${entity.name}: list page renders the declared columns`, async ({ page }) => {
24
+ await page.goto(manifest.standaloneShell + entity.route);
25
+ await expect(listCrumb(page, entity)).toBeVisible();
26
+ if (entity.hierarchy) {
27
+ if (entity.expectSeedData) {
28
+ await expect(page.getByRole('treeitem').first()).toBeVisible();
29
+ }
30
+ return;
31
+ }
32
+ if (entity.layout === 'calendar' || entity.layout === 'slots') {
33
+ // the view family renders a calendar / slot picker instead of the table
34
+ await expect(page.locator(CALENDAR_OR_SLOTS).first()).toBeVisible();
35
+ return;
36
+ }
37
+ // filter({ visible: true }): the empty-state markup stays in the DOM (x-show) above the
38
+ // table, so an unfiltered union's .first() would pick the hidden element and always fail
39
+ const firstHeader = page.getByRole('columnheader').filter({ visible: true }).first();
40
+ const emptyState = page.getByText('Get started by creating the first record').filter({ visible: true }).first();
41
+ await expect(firstHeader.or(emptyState).first()).toBeVisible();
42
+ if (entity.expectSeedData || (await firstHeader.isVisible())) {
43
+ for (const label of declaredColumns(entity)) {
44
+ await expect(page.getByRole('columnheader').filter({ hasText: label }).first()).toBeVisible();
45
+ }
46
+ for (const label of hiddenColumns(entity)) {
47
+ await expect(page.getByRole('columnheader').filter({ hasText: label })).toHaveCount(0);
48
+ }
49
+ await expect(page.locator('tbody tr:visible').first()).toBeVisible();
50
+ }
51
+ });
52
+ }
53
+
54
+ // The headers the list is expected to render, in order (dirigible #7664). `list:` curates the list
55
+ // INDEPENDENTLY of each field's own `major`, so an entity that declares it renders exactly those
56
+ // columns and the `major` rule says nothing about it - which is why asserting the rule against a
57
+ // curated entity failed on the first instance that held a row, and passed on every empty one,
58
+ // where the assertion never runs at all.
59
+ function declaredColumns(entity) {
60
+ if (entity.list?.length) return entity.list.map((name) => columnLabel(entity, name));
61
+ return (entity.fields ?? []).filter((f) => f.major !== false && !f.primaryKey).map(labelOf);
62
+ }
63
+
64
+ // ...and the ones it must NOT render. A curated list is the whole statement, so a field left off it
65
+ // is absent from the table however its own `major` reads - the half that catches a `list:` the page
66
+ // ignored.
67
+ function hiddenColumns(entity) {
68
+ if (!entity.list?.length) return [];
69
+ const listed = new Set(entity.list);
70
+ return (entity.fields ?? [])
71
+ .filter((f) => !f.primaryKey && !listed.has(f.name))
72
+ .map(labelOf);
73
+ }
74
+
75
+ // A `list:` entry names a property - a field, or a to-one relation - and the header carries that
76
+ // property's own label, which is what the manifest's `label` on the field already is.
77
+ function columnLabel(entity, name) {
78
+ const field = (entity.fields ?? []).find((f) => f.name === name);
79
+ if (field) return labelOf(field);
80
+ const relation = (entity.relations ?? []).find((r) => r.name === name);
81
+ return relation?.label ?? name;
82
+ }
@@ -0,0 +1,68 @@
1
+ import { expect, test } from '../fixtures.js';
2
+ import { listCrumb } from './list.js';
3
+
4
+ const LANGUAGE_KEY = 'codbex.harmonia.language';
5
+ // A reload in another language is three round trips on a cold instance - the page, the list's
6
+ // data GET and the translation overlay behind it - so the 5 s default flaked on slower runners.
7
+ const OVERLAY_TIMEOUT = 15_000;
8
+
9
+ // The read-time translation overlay: switch the shared language key (what the Region &
10
+ // Language setting writes), reload, and a known seed row shows its translated name.
11
+ // Needs a concrete sample in the manifest: { language, base, translated }.
12
+ export function multilingualFlow(manifest, entity) {
13
+ const sample = entity.multilingualSample;
14
+ if (!entity.multilingual || !sample || !entity.route) return;
15
+
16
+ test(`${entity.name}: ${sample.language} translation overlays on read`, async ({ page }) => {
17
+ await page.goto(manifest.standaloneShell + entity.route);
18
+ await expect(listCrumb(page, entity)).toBeVisible();
19
+ const translatedRead = await reloadIn(page, manifest, entity, sample.language);
20
+ await expectRowShowing(page, sample.translated, translatedRead);
21
+ const baseRead = await reloadIn(page, manifest, entity, 'en');
22
+ await expectRowShowing(page, sample.base, baseRead);
23
+ });
24
+ }
25
+
26
+ // Set the language key and reload, waiting for the list's own data GET (the paged getAll on the
27
+ // entity's controller) so the assertion starts once the rows are on their way, and answering what
28
+ // that read asked for and got back - which is what tells a slow render from a missing translation.
29
+ // The wait is registered only once the reload has committed: registered before it, it also matched
30
+ // a list GET still in flight from the document the reload replaced, whose body is gone (#7584). The
31
+ // new document issues its own GET only after its scripts have loaded, so it cannot slip past.
32
+ async function reloadIn(page, manifest, entity, language) {
33
+ await page.evaluate(([key, lang]) => localStorage.setItem(key, lang), [LANGUAGE_KEY, language]);
34
+ const controllerPath = manifest.restBase + entity.api;
35
+ await page.reload({ waitUntil: 'commit' });
36
+ const response = await page.waitForResponse(
37
+ (candidate) => candidate.request().method() === 'GET' && new URL(candidate.url()).pathname === controllerPath,
38
+ { timeout: OVERLAY_TIMEOUT },
39
+ );
40
+ return {
41
+ language,
42
+ acceptLanguage: await response.request().headerValue('accept-language'),
43
+ status: response.status(),
44
+ // Only the diagnostic reads the body, so a body the browser no longer holds must not fail the flow.
45
+ body: await response.text().catch(() => null),
46
+ };
47
+ }
48
+
49
+ async function expectRowShowing(page, text, read) {
50
+ const rows = page.locator('tbody tr');
51
+ try {
52
+ await expect(rows.filter({ hasText: text }).first()).toBeVisible({ timeout: OVERLAY_TIMEOUT });
53
+ } catch (error) {
54
+ const firstRow = (await rows.allInnerTexts())[0]?.replace(/\s+/g, ' ').trim();
55
+ throw new Error(
56
+ `no list row shows '${text}' after reloading in '${read.language}': the list GET sent Accept-Language ` +
57
+ `${read.acceptLanguage ?? '(none)'} and answered ${read.status}, ` +
58
+ `${describeBody(read.body, text)}; the table's first row reads ` +
59
+ `${firstRow ? `'${firstRow}'` : '(no rows)'}`,
60
+ { cause: error },
61
+ );
62
+ }
63
+ }
64
+
65
+ function describeBody(body, text) {
66
+ if (body === null) return 'its body unavailable';
67
+ return `${body.includes(text) ? 'WITH' : 'WITHOUT'} '${text}' in its body`;
68
+ }
@@ -0,0 +1,156 @@
1
+ import { makeApi } from '../api.js';
2
+ import { expect, test } from '../fixtures.js';
3
+ import { resolveRelationSamples } from '../form.js';
4
+ import { freshUniques, sampleRecord } from '../sample-values.js';
5
+ import { CALENDAR_OR_SLOTS } from './list.js';
6
+
7
+ // Wave 2 (UI parity): the personal PAGE renders what the wire serves. Driven by the same
8
+ // own-row lifecycle as the wire test (created through the scoped controller, removed in the
9
+ // finally); skips without an identity mapping, exactly like the wire flow.
10
+ // - layout 'list': every relation column of the own row shows its referenced LABEL - the
11
+ // raw-FK-id regression class the personal templates fixed;
12
+ // - layout 'calendar'/'slots': the personal route renders the calendar / slot picker, never a
13
+ // plain form+list downgrade;
14
+ // - layout 'document-chat': the composer accepts a message and the bubble renders (the my
15
+ // document chat parity class). A plain 'document' asserts the page serves.
16
+ function myUiTest(manifest, entity, mine, buildContext, idProperty) {
17
+ test(`${entity.name}: the My page renders the own record with resolved labels (UI parity)`, async ({ api, page }) => {
18
+ const { payload, samples } = await buildContext(api);
19
+ const createdResponse = await api.post(manifest.restBase + mine.api, { data: payload });
20
+ test.skip(
21
+ !createdResponse.ok(),
22
+ `personal create returned ${createdResponse.status()} - the test user has no identity mapping; ` +
23
+ `seed the dev identity row (an owner-target record whose identity field equals the login user)`
24
+ );
25
+ const created = JSON.parse(await createdResponse.text());
26
+ const id = created?.[idProperty];
27
+ const client = makeApi(api, manifest);
28
+ try {
29
+ const layout = entity.personal.layout ?? 'list';
30
+ if (layout === 'calendar' || layout === 'slots') {
31
+ await page.goto(manifest.standaloneShell + entity.personal.route);
32
+ await expect(page.locator(CALENDAR_OR_SLOTS).first()).toBeVisible();
33
+ return;
34
+ }
35
+ if (layout === 'document-chat' || layout === 'document') {
36
+ await page.goto(manifest.standaloneShell + entity.personal.route + '/' + id + '/edit');
37
+ if (layout === 'document-chat') {
38
+ const message = `UI parity probe ${id}`;
39
+ const composer = page.getByRole('textbox', { name: /write a message/i }).first();
40
+ await composer.fill(message);
41
+ await page.getByRole('button', { name: /send/i }).first().click();
42
+ await expect(page.getByText(message).first(), 'the sent message must render as a bubble').toBeVisible();
43
+ } else {
44
+ // visible only: the document carries a second toolbar title (its items table's), which can
45
+ // be the first in DOM order while hidden (dirigible #7545)
46
+ await expect(page.locator('[x-h-toolbar-title]').filter({ visible: true }).first()).toBeVisible();
47
+ }
48
+ return;
49
+ }
50
+ // list: the own rows are the only rows - every asserted relation column of the created
51
+ // row must surface its referenced label somewhere in the table body
52
+ await page.goto(manifest.standaloneShell + entity.personal.route);
53
+ await expect(page.locator('tbody tr:visible').first()).toBeVisible();
54
+ const fkColumns = new Set(entity.personal.fkColumns ?? []);
55
+ for (const sample of samples) {
56
+ if (!fkColumns.has(sample.relation.name) || sample.label == null) continue;
57
+ await expect(
58
+ page.locator('tbody').getByText(String(sample.label)).first(),
59
+ `${sample.relation.name} must render its referenced label ("${sample.label}"), not the raw id ${sample.id}`
60
+ ).toBeVisible();
61
+ }
62
+ } finally {
63
+ await client.remove(mine, id);
64
+ }
65
+ });
66
+ }
67
+
68
+ // The personal (my) surface WIRE contract, driven when the manifest marks an entity `personal`:
69
+ // the scoped <Entity>MyController filters reads to the identity-mapped user, forces the owner FK
70
+ // server-side on create (whatever the client sends is ignored), strips every `sensitive` field
71
+ // from its responses (the allow-list is the security boundary - UI hiding alone is cosmetic),
72
+ // serves foreign rows as 404, and deletes only own rows.
73
+ //
74
+ // Prerequisite: the DEV IDENTITY mapping - a row of the owner relation's target whose identity
75
+ // field equals the test user (e.g. an Employee with email 'admin'). Without it the personal
76
+ // surface is EMPTY by design (never an error), so the flow SKIPS with a pointer instead of
77
+ // failing. Wave 2 (myUiTest below) walks the personal PAGE over the same own-row lifecycle:
78
+ // resolved relation labels on the list, calendar/slots rendering, the chat composer round-trip.
79
+ export function myFlow(manifest, entity, opts = {}) {
80
+ const cfg = opts.extend?.entities?.[entity.name] ?? {};
81
+ if (!entity.personal || new Set(cfg.skip ?? []).has('my')) return;
82
+ const idProperty = manifest.idProperty ?? 'Id';
83
+ // the same entity through the scoped controller
84
+ const mine = { ...entity, api: entity.personal.api };
85
+
86
+ async function buildContext(api) {
87
+ const payload = sampleRecord(entity);
88
+ const samples = await resolveRelationSamples(api, manifest, entity);
89
+ for (const sample of samples) {
90
+ payload[sample.relation.name] = sample.id;
91
+ }
92
+ // the owner FK is server-forced on the personal surface; sending a value must be pointless
93
+ delete payload[entity.personal.owner];
94
+ await freshUniques(makeApi(api, manifest), entity, payload);
95
+ return { payload, samples };
96
+ }
97
+
98
+ async function buildPayload(api) {
99
+ return (await buildContext(api)).payload;
100
+ }
101
+
102
+ test(`${entity.name}: personal (my) surface scopes rows, forces the owner and strips sensitive fields`, async ({ api }) => {
103
+ const client = makeApi(api, manifest);
104
+
105
+ const createdResponse = await api.post(manifest.restBase + mine.api, { data: await buildPayload(api) });
106
+ test.skip(
107
+ !createdResponse.ok(),
108
+ `personal create returned ${createdResponse.status()} - the test user has no identity mapping; ` +
109
+ `seed the dev identity row (an owner-target record whose identity field equals the login user)`
110
+ );
111
+ const created = JSON.parse(await createdResponse.text());
112
+ const id = created?.[idProperty];
113
+ expect(id, 'personal create carries the generated id').toBeTruthy();
114
+ let foreignId;
115
+ let deleted;
116
+ try {
117
+ const own = await client.get(mine, id);
118
+ expect(own[entity.personal.owner], 'the owner FK is forced server-side on a personal create').toBeTruthy();
119
+ for (const field of entity.personal.sensitive ?? []) {
120
+ expect(own[field], `sensitive field ${field} must never reach the personal wire`).toBeFalsy();
121
+ }
122
+ const rows = await client.list(mine, 1000);
123
+ expect(rows.some((row) => row[idProperty] === id), 'the own row appears in the personal list').toBe(true);
124
+
125
+ // the power surface still serves the full record
126
+ const power = await client.get(entity, id);
127
+ expect(power[idProperty]).toBe(id);
128
+
129
+ // a row with no owner is foreign to everyone: the personal controller must 404 it and the
130
+ // personal list must not include it (only checkable when the owner relation is optional)
131
+ const ownerRelation = (entity.relations ?? []).find((relation) => relation.name === entity.personal.owner);
132
+ if (ownerRelation && !ownerRelation.required) {
133
+ const foreign = await client.create(entity, await buildPayload(api));
134
+ foreignId = foreign?.[idProperty];
135
+ const foreignThroughMine = await api.get(manifest.restBase + mine.api + '/' + foreignId);
136
+ expect(foreignThroughMine.status(), 'a foreign row must 404 through the personal controller').toBe(404);
137
+ const rowsAfter = await client.list(mine, 1000);
138
+ expect(rowsAfter.some((row) => row[idProperty] === foreignId), 'a foreign row must not appear in the personal list').toBe(false);
139
+ }
140
+ } finally {
141
+ deleted = await client.remove(mine, id); // deleting the OWN row through the personal controller works
142
+ if (foreignId) await client.remove(entity, foreignId);
143
+ }
144
+ // ... unless a `whenDeleted: refuse` process guards the record while its instance runs
145
+ if (deleted) {
146
+ const gone = await api.get(manifest.restBase + mine.api + '/' + id);
147
+ expect(gone.status()).toBe(404);
148
+ }
149
+ });
150
+
151
+ // wave 2: the UI-parity walk over the same personal surface (older manifests without the
152
+ // route/layout metadata regenerate to get it - skip quietly until then)
153
+ if (entity.personal.route) {
154
+ myUiTest(manifest, entity, mine, buildContext, idProperty);
155
+ }
156
+ }
@@ -0,0 +1,57 @@
1
+ import { makeApi } from '../api.js';
2
+ import { expect, test } from '../fixtures.js';
3
+ import { resolveRelationSamples } from '../form.js';
4
+ import { freshUniqueKeys, freshUniques, 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
+ const samples = await resolveRelationSamples(api, manifest, entity);
20
+ const taken = await freshUniqueKeys(client, entity, payload, samples);
21
+ test.skip(!!taken, `every ${entity.name} combination of the unique key [${taken}] tried already has a row`);
22
+ for (const sample of samples) {
23
+ payload[sample.relation.name] = sample.id;
24
+ }
25
+ await freshUniques(client, entity, payload);
26
+
27
+ const created = await client.create(entity, payload);
28
+ const id = created?.[idProperty];
29
+ expect(id, 'create response carries the generated id').toBeTruthy();
30
+ let deleted;
31
+ try {
32
+ const read = await client.get(entity, id);
33
+ expect(read[idProperty]).toBe(id);
34
+ // the update round-trip needs a writable string field to flip; skip it when there is none
35
+ if (handle) {
36
+ expect(read[handle.name]).toBe(payload[handle.name]);
37
+
38
+ const updatedValue = payload[handle.name] + '-UPD';
39
+ await client.update(entity, id, { ...read, [handle.name]: updatedValue });
40
+ const reread = await client.get(entity, id);
41
+ expect(reread[handle.name]).toBe(updatedValue);
42
+ }
43
+ } finally {
44
+ deleted = await client.remove(entity, id);
45
+ }
46
+ if (!deleted) {
47
+ // the record's process guard refused the delete (409) - assert the other half of that promise:
48
+ // the row is still served. A guarded entity can also delete cleanly, when its instance had
49
+ // already finished, so which outcome is expected is only knowable after the attempt.
50
+ const kept = await client.get(entity, id);
51
+ expect(kept[idProperty], `delete refused by ${entity.deleteGuardedByProcess.join(', ')} - the record must survive`).toBe(id);
52
+ return;
53
+ }
54
+ const gone = await client.getResponse(entity, id);
55
+ expect(gone.status()).toBe(404);
56
+ });
57
+ }
@@ -0,0 +1,17 @@
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
+ if (!entity.route) return; // no power page, no menu item
8
+ test(`${entity.name}: shared shell menu item opens the module`, async ({ page }) => {
9
+ test.skip(!process.env.APPTEST_SHELL, 'APPTEST_SHELL=1 enables the shared shell flow');
10
+ await page.goto('/services/web/application/');
11
+ const item = page.getByText(entity.labelPlural, { exact: true }).first();
12
+ await expect(item).toBeVisible();
13
+ await item.click();
14
+ const app = page.frameLocator('iframe').first();
15
+ await expect(app.getByText(entity.labelPlural, { exact: true }).first()).toBeVisible();
16
+ });
17
+ }