Skip to content

Commit 6ec4090

Browse files
delchevclaude
andauthored
fix(apptest): manifest mirrors the rendered UI; runner handles empty/tree/calendar layouts (#6323)
* fix(apptest): manifest mirrors the rendered UI; runner handles empty/tree/calendar layouts Running the generated <name>.test manifests across all 28 KeyFolders intent modules (the first fleet-wide consumer) surfaced six defect classes; every fix keeps the pilot (kf-mod-countries) green. AppTestIntentGenerator: - readOnly now mirrors the generated form exactly: an authored readOnly, a uuid field, or a calculated field renders without an editable input, so the runner must not try to fill it (a Company/Customer/Employee Uuid made fillForm wait 60s on a non-existent #f_Uuid input). - emits hierarchy: true for tree entities (Account rendered role=treeitem, no columnheaders - the list flow asserted a table that never exists). - layout maps MANAGE_CALENDAR -> calendar and MANAGE_SLOTS -> slots (a view: range/slots entity was reported manage-list and the runner walked a table that is a calendar). npm/test runner: - list flow: an entity with no rows renders the Harmonia empty state (no table at all) - assert columns only when the table is present, keep the strict row assertion when expectSeedData; tree entities assert treeitems; calendar/slots assert the calendar/slot-picker container. - crud flow: 'New' button located with exact: true (the empty state adds a second 'New <Entity>' button that a substring match also hits); tree and calendar/slots entities skip the UI walk (REST covers CRUD); entities without a string handle field skip it too instead of throwing. - rest flow: degrades gracefully without a string handle (create/read/ delete still assert; the update-value round-trip is skipped). - pickDropdown: combobox located with exact: true ('Type' also matched 'Chart Type'). - api client: the error path called response.request(), which does not exist on Playwright APIResponse - the thrower itself threw and masked every real REST failure. Unit tests extended (readOnly/hierarchy emission, entity counts). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(apptest): visibility-filtered list union; anchored combobox name match - list.js: the empty-state markup stays in the DOM (x-show) above the table, so the unfiltered union's .first() picked the hidden element and failed on every list WITH data; filter({ visible: true }) on both arms. - form.js: exact combobox matching found nothing (the accessible name is the label plus placeholder/selected value); an anchored prefix regex keeps the 'Type' vs 'Chart Type' collision fixed without breaking the normal case. Countries pilot re-verified green on a live instance after both. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(apptest): cross-model relation resolution; entityStatus + aggregate awareness The fleet run's remaining systemic failure: every transactional document (SalesInvoice, ExpenseClaim, SalaryAssignment, PurchaseInvoice, SalesOrder, ...) has REQUIRED cross-model FKs (Customer, Employee, Supplier), which the manifest omitted - so both the UI create and the REST create posted a payload missing a required FK and got 400. AppTestIntentGenerator: - cross-model to-one relations are now emitted WITH an apiAbsolute controller URL in the owner module (resolved via CrossModelSupport, the same coordinates the generated dropdowns use) + the owner's label field; unresolvable targets are omitted with a warning (the EDM generator already fails loudly for truly missing owners). - a function: EntityStatus relation is marked entityStatus: the form templates exclude it from editable inputs (status pill) and its value comes from the init: DB default - the runner must neither pick nor post it. - an aggregate field is auto-readOnly (renders in the document totals footer, not as an input - CreditNote.Net made fillForm wait forever). npm/test runner: - resolveRelationSamples: skips entityStatus relations, resolves cross-model rows via apiAbsolute (new api.listPath), leaves an OPTIONAL relation unset when its target has no rows (only a required one throws); the REST flow now shares this resolution instead of duplicating it. - crud.js: all action buttons located with exact: true - Playwright's default name matching is a case-insensitive substring, so 'Edit' also matched the 'CrEDIT Notes' sidebar item and navigated away mid-flow. Unit tests extended (cross-model apiAbsolute via convention fallback, aggregate readOnly). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(apptest): document-save flow, toolbar search lookup, detail-target relations, Instant samples Fourth defect wave from the 28-module fleet run: - crud.js filterBy: row lookup goes through the toolbar 'Search <Entity>...' box (present on every list layout) instead of the per-column filter row, whose first input can belong to an FK column (x-show hidden - fill hung) or a date column; master-detail pages have no filter row at all. - crud.js: a document-layout form deliberately stays on the record after Save (header-items editing continues) - assert /edit and navigate back to the list instead of expecting the list URL. - AppTestIntentGenerator + runner: same-model relations carry their relative controller path (api), so a relation targeting a composition DETAIL (excluded from the manifest's entities list - PayrollEntry -> Payslip) still resolves sample rows. - sample-values/form: timestamp samples are full ISO instants (the generated entities bind java.time.Instant, which rejects a zone-less value - Appointment REST create got 400); the UI fill slices to the datetime-local shape. Unit tests extended (same-model relation api emission). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(apptest): document-create flow, dependsOn-consistent samples, where filters, exactlyOne checks Fifth (and final) defect wave from the 28-module fleet run - all generic DSL features the runner must honor: - crud.js: a document CREATE lands on the new record's page (line-item editing continues there), like Save - assert /edit and navigate back. - dependsOn cascade (Country -> City): independent first-row samples pick e.g. Country=Afghanistan + City=Sofia, and the narrowed dropdown then offers no matching option. The manifest now carries the relation's dependsOn {relation, filterBy}; the runner picks the dependent row first, re-points the trigger sample at the row's FK, and fills triggers before dependents. A dependsOn FIELD (auto-populated, e.g. SupplierNumber) is marked readOnly - the watcher fills it, not the runner. - where: static option filters (a stock line's Product picker offering only Type=1) ride into the manifest; the runner picks a MATCHING row client-side instead of the first one. - exactlyOne checks (journal PostingRule / JournalEntry lines): a sample filling every field is rejected with 400 - the manifest carries the check's field sets and sampleRecord keeps only the first of each. Unit tests extended (dependsOn/where/exactlyOne emission). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(apptest): leaf-aware samples, edit-load wait, document-create flow, option-click retry Final defect wave - the full 28-module KeyFolders fleet is green with these: - leafOnly relations (chart-of-accounts pickers): the generated validation rejects a non-leaf target, but the runner sampled the FIRST account row ('10 Capital', a group) and got 400 while the UI picker (leaves only) passed. The manifest carries leafOnly {hierarchyProperty} (same-model from the target's hierarchy:, cross-model from TargetInfo); the runner picks a row no other row parents. - crud edit flow: the record loads async after the form renders - filling before the fetch completes got overwritten by the load, and Save persisted the OLD value (services Ticket caught it; my manual replay passed only because of its think-time waits). The runner now waits for the handle input to show the loaded value before typing. - document layout, definitively: CREATE lands on the new record's page (line-item editing continues there); SAVE from an edit returns to the list. (The two earlier commits had each half inverted.) - pickDropdown: an option list re-rendering mid-click (async load reflow) made the click retry forever - reopen and force-retry once. - resolveRelationSamples: an optional relation whose target has no name-like label field is left unset instead of clicking blind. Unit tests extended (leafOnly emission). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 5d3b990 commit 6ec4090

8 files changed

Lines changed: 384 additions & 57 deletions

File tree

components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/apptest/AppTestIntentGenerator.java

Lines changed: 137 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -18,11 +18,14 @@
1818
import org.eclipse.dirigible.components.intent.generator.IntentGenerationContext;
1919
import org.eclipse.dirigible.components.intent.generator.IntentNaming;
2020
import org.eclipse.dirigible.components.intent.generator.IntentTargetGenerator;
21+
import org.eclipse.dirigible.components.intent.generator.edm.CrossModelSupport;
22+
import org.eclipse.dirigible.components.intent.model.CheckIntent;
2123
import org.eclipse.dirigible.components.intent.model.EntityIntent;
2224
import org.eclipse.dirigible.components.intent.model.FieldIntent;
2325
import org.eclipse.dirigible.components.intent.model.IntentModel;
2426
import org.eclipse.dirigible.components.intent.model.RelationIntent;
2527
import org.eclipse.dirigible.components.intent.model.SeedIntent;
28+
import org.eclipse.dirigible.components.intent.model.UsesIntent;
2629
import org.eclipse.dirigible.repository.api.IRepository;
2730
import org.eclipse.dirigible.repository.api.IResource;
2831
import org.slf4j.Logger;
@@ -83,7 +86,7 @@ public void generate(IntentGenerationContext context) {
8386
return;
8487
}
8588

86-
Map<String, Object> manifest = buildManifest(baseName, context.getProjectName(), model, edmEntities);
89+
Map<String, Object> manifest = buildManifest(baseName, context.getProjectName(), model, edmEntities, context);
8790
context.writeModelFile(baseName + ".test", GSON.toJson(manifest) + "\n");
8891
LOGGER.debug("Generated app-test manifest [{}.test]", baseName);
8992
}
@@ -102,6 +105,15 @@ public void generate(IntentGenerationContext context) {
102105
*/
103106
public static Map<String, Object> buildManifest(String baseName, String project, IntentModel model,
104107
Map<String, Map<String, Object>> edmEntities) {
108+
return buildManifest(baseName, project, model, edmEntities, null);
109+
}
110+
111+
/**
112+
* The full variant carrying the generation context, which cross-model relation resolution needs (a
113+
* {@code null} context falls back to the naming-convention target coordinates - unit tests).
114+
*/
115+
public static Map<String, Object> buildManifest(String baseName, String project, IntentModel model,
116+
Map<String, Map<String, Object>> edmEntities, IntentGenerationContext context) {
105117
Map<String, Object> manifest = new LinkedHashMap<>();
106118
manifest.put("module", baseName);
107119
manifest.put("standaloneShell", "/services/web/" + project + "/gen/" + baseName + "/index.html");
@@ -120,13 +132,14 @@ public static Map<String, Object> buildManifest(String baseName, String project,
120132
if ("MANAGE_DETAILS".equals(string(edm.get("layoutType"))) || "PROJECTION".equals(string(edm.get("type")))) {
121133
continue;
122134
}
123-
entities.add(entityManifest(entity, edm, model));
135+
entities.add(entityManifest(entity, edm, model, context, edmEntities));
124136
}
125137
manifest.put("entities", entities);
126138
return manifest;
127139
}
128140

129-
private static Map<String, Object> entityManifest(EntityIntent entity, Map<String, Object> edm, IntentModel model) {
141+
private static Map<String, Object> entityManifest(EntityIntent entity, Map<String, Object> edm, IntentModel model,
142+
IntentGenerationContext context, Map<String, Map<String, Object>> edmEntities) {
130143
Map<String, Object> out = new LinkedHashMap<>();
131144
String name = entity.getName();
132145
out.put("name", name);
@@ -138,6 +151,12 @@ private static Map<String, Object> entityManifest(EntityIntent entity, Map<Strin
138151
out.put("api", "/" + sanitizeJavaIdentifier(string(edm.get("perspectiveName"))) + "/" + name + "Controller");
139152
out.put("table", string(edm.get("dataName")));
140153

154+
// A hierarchical entity renders its list as a tree (role=treeitem, no table/columnheaders),
155+
// so the runner must branch on it.
156+
if (entity.getHierarchy() != null && !entity.getHierarchy()
157+
.isBlank()) {
158+
out.put("hierarchy", true);
159+
}
141160
boolean multilingual = "true".equals(string(edm.get("multilingual")));
142161
if (multilingual) {
143162
out.put("multilingual", true);
@@ -149,8 +168,23 @@ private static Map<String, Object> entityManifest(EntityIntent entity, Map<Strin
149168
if (hasSeed(model, name)) {
150169
out.put("expectSeedData", true);
151170
}
171+
// exactlyOne checks: exactly one of the named fields may be non-null - a sample record
172+
// filling all of them is rejected with 400, so the runner keeps only the first
173+
List<List<String>> exactlyOne = new ArrayList<>();
174+
for (CheckIntent check : entity.getChecks() == null ? List.<CheckIntent>of() : entity.getChecks()) {
175+
if ("exactlyOne".equals(check.getKind()) && check.getFields() != null && !check.getFields()
176+
.isEmpty()) {
177+
exactlyOne.add(check.getFields()
178+
.stream()
179+
.map(IntentNaming::pascalCase)
180+
.toList());
181+
}
182+
}
183+
if (!exactlyOne.isEmpty()) {
184+
out.put("exactlyOne", exactlyOne);
185+
}
152186
out.put("fields", fields(entity));
153-
List<Map<String, Object>> relations = relations(entity, model);
187+
List<Map<String, Object>> relations = relations(entity, model, context, edmEntities);
154188
if (!relations.isEmpty()) {
155189
out.put("relations", relations);
156190
}
@@ -176,7 +210,13 @@ private static List<Map<String, Object>> fields(EntityIntent entity) {
176210
if (field.getLength() != null) {
177211
out.put("length", field.getLength());
178212
}
179-
if (field.isReadOnly()) {
213+
// Read-only must mirror the generated form exactly, or the runner waits forever on an
214+
// input that is not there: an author-marked field and a uuid render in the read-only
215+
// details block (no #f_<Name> input), a calculated field renders as a non-editable
216+
// input, an aggregate renders in the document totals footer, and a dependsOn field is
217+
// auto-populated by its trigger relation's watcher (the runner must not fill it).
218+
if (field.isReadOnly() || "uuid".equalsIgnoreCase(field.getType()) || field.isCalculated() || field.isAggregate()
219+
|| field.getDependsOn() != null) {
180220
out.put("readOnly", true);
181221
}
182222
out.put("major", field.isMajor());
@@ -186,15 +226,26 @@ private static List<Map<String, Object>> fields(EntityIntent entity) {
186226
}
187227

188228
/**
189-
* The user-pickable to-one relations rendered as dropdowns. Cross-model relations are omitted —
190-
* their target lives in another module's manifest, so a single-module runner cannot resolve a
191-
* sample option for them (a phase-2 concern).
229+
* The user-pickable to-one relations rendered as dropdowns. A cross-model relation's target lives
230+
* in another module — its option rows are resolved through an {@code apiAbsolute} controller URL
231+
* (the same owner-project coordinates the generated dropdown uses), so the runner can fill the
232+
* required FK without the target being in this manifest. A {@code function: EntityStatus} relation
233+
* is marked {@code entityStatus} — it renders as a status pill / is excluded from the editable
234+
* inputs by the form templates, and its value comes from the {@code init:} DB default, so the
235+
* runner must neither pick nor post it.
192236
*/
193-
private static List<Map<String, Object>> relations(EntityIntent entity, IntentModel model) {
237+
private static List<Map<String, Object>> relations(EntityIntent entity, IntentModel model, IntentGenerationContext context,
238+
Map<String, Map<String, Object>> edmEntities) {
239+
Map<String, UsesIntent> usesByAlias = new LinkedHashMap<>();
240+
for (UsesIntent uses : model.getUses()) {
241+
if (uses.getModel() != null) {
242+
usesByAlias.put(uses.getModel(), uses);
243+
}
244+
}
194245
List<Map<String, Object>> relations = new ArrayList<>();
195246
for (RelationIntent relation : entity.getRelations()) {
196247
boolean toOne = "manyToOne".equals(relation.getKind()) || "oneToOne".equals(relation.getKind());
197-
if (!toOne || relation.isCrossModel() || relation.getTo() == null) {
248+
if (!toOne || relation.getTo() == null) {
198249
continue;
199250
}
200251
Map<String, Object> out = new LinkedHashMap<>();
@@ -205,7 +256,79 @@ private static List<Map<String, Object>> relations(EntityIntent entity, IntentMo
205256
out.put("required", true);
206257
}
207258
out.put("widget", "dropdown");
208-
out.put("labelFrom", labelFieldOf(relation.getTo(), model));
259+
if (relation.isEntityStatus()) {
260+
out.put("entityStatus", true);
261+
}
262+
// dependsOn cascade: the option list narrows to target rows whose filterBy equals the
263+
// trigger sibling's value - the runner must pick MATCHING samples (the dependent row
264+
// first, then its FK as the trigger's sample), not independent first rows.
265+
if (relation.getDependsOn() != null) {
266+
Map<String, Object> dependsOn = new LinkedHashMap<>();
267+
dependsOn.put("relation", IntentNaming.pascalCase(relation.getDependsOn()
268+
.getRelation()));
269+
if (relation.getDependsOn()
270+
.getFilterBy() != null) {
271+
dependsOn.put("filterBy", IntentNaming.pascalCase(relation.getDependsOn()
272+
.getFilterBy()));
273+
}
274+
out.put("dependsOn", dependsOn);
275+
}
276+
// where: static option filter - only matching target rows are offered as options
277+
if (relation.getWhere() != null && relation.getWhere()
278+
.size() == 1) {
279+
Map.Entry<String, Object> condition = relation.getWhere()
280+
.entrySet()
281+
.iterator()
282+
.next();
283+
Map<String, Object> where = new LinkedHashMap<>();
284+
where.put("by", IntentNaming.pascalCase(condition.getKey()));
285+
where.put("value", condition.getValue());
286+
out.put("where", where);
287+
}
288+
if (relation.isCrossModel()) {
289+
UsesIntent uses = usesByAlias.get(relation.getModel());
290+
if (uses == null) {
291+
continue;
292+
}
293+
CrossModelSupport.TargetInfo info;
294+
try {
295+
info = CrossModelSupport.resolve(context, uses, relation.getTo());
296+
} catch (RuntimeException ex) {
297+
// the EDM generator (order 200) fails loudly for a truly unresolvable target;
298+
// reaching here means a degraded context - omit the relation rather than emit a
299+
// guessed URL
300+
LOGGER.warn("Omitting cross-model relation [{}] of [{}] from the app-test manifest - target unresolved",
301+
relation.getName(), entity.getName(), ex);
302+
continue;
303+
}
304+
out.put("crossModel", true);
305+
out.put("apiAbsolute", "/services/java/" + uses.resolveProject() + "/gen/" + sanitizeJavaIdentifier(uses.getModel())
306+
+ "/api/" + sanitizeJavaIdentifier(info.perspectiveName()) + "/" + relation.getTo() + "Controller");
307+
out.put("labelFrom", info.labelField());
308+
// leafOnly: the generated validation rejects a non-leaf target - the runner must
309+
// pick a row no other row references via the target's hierarchy edge
310+
if (relation.isLeafOnly() && info.hierarchyProperty() != null) {
311+
out.put("leafOnly", Map.of("hierarchyProperty", info.hierarchyProperty()));
312+
}
313+
} else {
314+
// relative controller path of the same-model target - resolvable even when the
315+
// target is a composition detail (excluded from this manifest's entities list)
316+
Map<String, Object> targetEdm = edmEntities.get(relation.getTo());
317+
if (targetEdm != null) {
318+
out.put("api",
319+
"/" + sanitizeJavaIdentifier(string(targetEdm.get("perspectiveName"))) + "/" + relation.getTo() + "Controller");
320+
}
321+
out.put("labelFrom", labelFieldOf(relation.getTo(), model));
322+
if (relation.isLeafOnly()) {
323+
for (EntityIntent target : model.getEntities()) {
324+
if (relation.getTo()
325+
.equals(target.getName())
326+
&& target.getHierarchy() != null) {
327+
out.put("leafOnly", Map.of("hierarchyProperty", IntentNaming.pascalCase(target.getHierarchy())));
328+
}
329+
}
330+
}
331+
}
209332
relations.add(out);
210333
}
211334
return relations;
@@ -333,6 +456,9 @@ private static String idProperty(Map<String, Map<String, Object>> edmEntities) {
333456
private static String layout(String layoutType) {
334457
return switch (layoutType == null ? "" : layoutType) {
335458
case "MANAGE_DOCUMENT" -> "document";
459+
// the view family replaces the table page - the runner must not expect columns/rows
460+
case "MANAGE_CALENDAR" -> "calendar";
461+
case "MANAGE_SLOTS" -> "slots";
336462
default -> "manage-list";
337463
};
338464
}

0 commit comments

Comments
 (0)