diff --git a/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/IntentGenerationService.java b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/IntentGenerationService.java index 9d275cac993..96fcefea98c 100644 --- a/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/IntentGenerationService.java +++ b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/IntentGenerationService.java @@ -47,7 +47,7 @@ public class IntentGenerationService { * project root are owned (and scrubbed) by the generation pass. */ private static final Set INTENT_OWNED_EXTENSIONS = Set.of(".edm", ".model", ".bpmn", ".form", ".report", ".roles", ".access", - ".dsm", ".schema", ".table", ".view", ".csvim", ".csv", ".glue", ".print", ".extension"); + ".dsm", ".schema", ".table", ".view", ".csvim", ".csv", ".glue", ".print", ".extension", ".test"); private final List generators; private final IRepository repository; diff --git a/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/apptest/AppTestIntentGenerator.java b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/apptest/AppTestIntentGenerator.java new file mode 100644 index 00000000000..f396d453664 --- /dev/null +++ b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/apptest/AppTestIntentGenerator.java @@ -0,0 +1,406 @@ +/* + * Copyright (c) 2010-2026 Eclipse Dirigible contributors + * + * All rights reserved. This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v2.0 which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v20.html + * + * SPDX-FileCopyrightText: Eclipse Dirigible contributors SPDX-License-Identifier: EPL-2.0 + */ +package org.eclipse.dirigible.components.intent.generator.apptest; + +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import org.eclipse.dirigible.components.intent.generator.IntentGenerationContext; +import org.eclipse.dirigible.components.intent.generator.IntentNaming; +import org.eclipse.dirigible.components.intent.generator.IntentTargetGenerator; +import org.eclipse.dirigible.components.intent.model.EntityIntent; +import org.eclipse.dirigible.components.intent.model.FieldIntent; +import org.eclipse.dirigible.components.intent.model.IntentModel; +import org.eclipse.dirigible.components.intent.model.RelationIntent; +import org.eclipse.dirigible.components.intent.model.SeedIntent; +import org.eclipse.dirigible.repository.api.IRepository; +import org.eclipse.dirigible.repository.api.IResource; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.core.annotation.Order; +import org.springframework.stereotype.Component; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; + +/** + * Emits one {@code .test} manifest per module — the app-integration-test counterpart of the + * generated application. The manifest is a JSON description of WHAT the generated app promises + * (entities, layouts, per-field metadata, seeds, languages, REST + shell coordinates); the generic + * Playwright runner {@code @aerokit/test} reads it and knows HOW to verify each promise against the + * generated Harmonia UI and the reused REST controllers. So one runner, versioned with the + * templates whose markup it drives, checks every generated app the same way — no per-entity spec + * files to drift. + * + *

+ * The manifest is intent-owned and scrub-managed like every other model file (extension + * {@code .test} — a free extension; JS {@code *.test.js} files carry extension {@code js}, so there + * is no clash). It is regenerated on every Generate + * ({@link IntentGenerationContext#writeModelFile(String, String)}), never hand-edited: the + * per-module {@code test/} harness (a few files at the repo root) references it but does not own + * it. + * + *

+ * Runs at {@code @Order(900)} — after the {@code EdmIntentGenerator} (200) has written the + * {@code .model}, whose resolved per-entity metadata (perspective → REST path, {@code dataName} → + * table, {@code menuLabel} → plural label, layout type, nav group, {@code multilingual}) is the + * same source the Harmonia templates consume; this generator reads it back rather than re-deriving + * it. The logical per-field/relation data (types, required, unique, length, major) comes straight + * from the intent model. + */ +@Component +@Order(900) +public class AppTestIntentGenerator implements IntentTargetGenerator { + + private static final Logger LOGGER = LoggerFactory.getLogger(AppTestIntentGenerator.class); + + private static final Gson GSON = new GsonBuilder().setPrettyPrinting() + .disableHtmlEscaping() + .create(); + + @Override + public String name() { + return "test"; + } + + @Override + public void generate(IntentGenerationContext context) { + IntentModel model = context.getModel(); + String baseName = IntentNaming.baseName(context); + Map> edmEntities = readModelEntities(context, baseName); + if (edmEntities.isEmpty()) { + LOGGER.debug("Skipping app-test manifest for [{}] — no .model entities to describe", baseName); + return; + } + + Map manifest = buildManifest(baseName, context.getProjectName(), model, edmEntities); + context.writeModelFile(baseName + ".test", GSON.toJson(manifest) + "\n"); + LOGGER.debug("Generated app-test manifest [{}.test]", baseName); + } + + /** + * Assembles the manifest map from the intent model and the EDM-derived per-entity metadata — the + * pure, repository-free core of this generator (so it is unit-testable independently of the + * workspace I/O). + * + * @param baseName the intent base name (module id + web/gen folder) + * @param project the workspace project folder + * @param model the parsed intent model (logical field/relation data) + * @param edmEntities the {@code .model} entities indexed by name (resolved perspective, table, + * labels, layout, nav group, multilingual) + * @return the ordered manifest map ready to serialize as {@code .test} + */ + public static Map buildManifest(String baseName, String project, IntentModel model, + Map> edmEntities) { + Map manifest = new LinkedHashMap<>(); + manifest.put("module", baseName); + manifest.put("standaloneShell", "/services/web/" + project + "/gen/" + baseName + "/index.html"); + manifest.put("restBase", "/services/java/" + project + "/gen/" + sanitizeJavaIdentifier(baseName) + "/api"); + manifest.put("idProperty", idProperty(edmEntities)); + manifest.put("languages", languages(model)); + + List> entities = new ArrayList<>(); + for (EntityIntent entity : model.getEntities()) { + Map edm = edmEntities.get(entity.getName()); + if (edm == null) { + continue; + } + // A composition detail child is exercised through its master, not as its own list; a + // cross-model projection has no local table or UI to drive. + if ("MANAGE_DETAILS".equals(string(edm.get("layoutType"))) || "PROJECTION".equals(string(edm.get("type")))) { + continue; + } + entities.add(entityManifest(entity, edm, model)); + } + manifest.put("entities", entities); + return manifest; + } + + private static Map entityManifest(EntityIntent entity, Map edm, IntentModel model) { + Map out = new LinkedHashMap<>(); + String name = entity.getName(); + out.put("name", name); + out.put("label", stringOr(edm.get("entityLabel"), IntentNaming.humanize(name))); + out.put("labelPlural", stringOr(edm.get("menuLabel"), IntentNaming.pluralize(IntentNaming.humanize(name)))); + out.put("layout", layout(string(edm.get("layoutType")))); + out.put("route", "#/" + name); + out.put("navGroup", string(edm.get("perspectiveNavId"))); + out.put("api", "/" + sanitizeJavaIdentifier(string(edm.get("perspectiveName"))) + "/" + name + "Controller"); + out.put("table", string(edm.get("dataName"))); + + boolean multilingual = "true".equals(string(edm.get("multilingual"))); + if (multilingual) { + out.put("multilingual", true); + Map sample = multilingualSample(entity, model); + if (sample != null) { + out.put("multilingualSample", sample); + } + } + if (hasSeed(model, name)) { + out.put("expectSeedData", true); + } + out.put("fields", fields(entity)); + List> relations = relations(entity, model); + if (!relations.isEmpty()) { + out.put("relations", relations); + } + return out; + } + + /** Non-PK, non-generated fields, with the metadata the runner needs to fill and assert them. */ + private static List> fields(EntityIntent entity) { + List> fields = new ArrayList<>(); + for (FieldIntent field : entity.getFields()) { + if (field.isPrimaryKey() || field.isGenerated()) { + continue; + } + Map out = new LinkedHashMap<>(); + out.put("name", IntentNaming.pascalCase(field.getName())); + out.put("type", logicalType(field.getType())); + if (field.isRequired()) { + out.put("required", true); + } + if (field.isUnique()) { + out.put("unique", true); + } + if (field.getLength() != null) { + out.put("length", field.getLength()); + } + if (field.isReadOnly()) { + out.put("readOnly", true); + } + out.put("major", field.isMajor()); + fields.add(out); + } + return fields; + } + + /** + * The user-pickable to-one relations rendered as dropdowns. Cross-model relations are omitted — + * their target lives in another module's manifest, so a single-module runner cannot resolve a + * sample option for them (a phase-2 concern). + */ + private static List> relations(EntityIntent entity, IntentModel model) { + List> relations = new ArrayList<>(); + for (RelationIntent relation : entity.getRelations()) { + boolean toOne = "manyToOne".equals(relation.getKind()) || "oneToOne".equals(relation.getKind()); + if (!toOne || relation.isCrossModel() || relation.getTo() == null) { + continue; + } + Map out = new LinkedHashMap<>(); + out.put("name", IntentNaming.pascalCase(relation.getName())); + out.put("kind", relation.getKind()); + out.put("to", relation.getTo()); + if (relation.isRequired() || relation.isComposition()) { + out.put("required", true); + } + out.put("widget", "dropdown"); + out.put("labelFrom", labelFieldOf(relation.getTo(), model)); + relations.add(out); + } + return relations; + } + + /** + * The label property of a relation target: its {@code name} field PascalCased, else {@code Name}. + */ + private static String labelFieldOf(String targetName, IntentModel model) { + for (EntityIntent entity : model.getEntities()) { + if (!targetName.equals(entity.getName())) { + continue; + } + for (FieldIntent field : entity.getFields()) { + if ("name".equalsIgnoreCase(field.getName())) { + return IntentNaming.pascalCase(field.getName()); + } + } + for (FieldIntent field : entity.getFields()) { + if (isStringType(field.getType()) && !field.isPrimaryKey()) { + return IntentNaming.pascalCase(field.getName()); + } + } + } + return "Name"; + } + + /** + * A concrete {@code {language, base, translated}} sample for the multilingual read overlay, derived + * from the entity's inline base + {@code language:} seeds — or null when it cannot be derived + * (file-backed seeds), in which case the runner simply skips the translation assertion. + */ + private static Map multilingualSample(EntityIntent entity, IntentModel model) { + SeedIntent base = null; + SeedIntent translated = null; + for (SeedIntent seed : model.getSeeds()) { + if (!entity.getName() + .equals(seed.getEntity()) + || seed.getRows() == null || seed.getRows() + .isEmpty()) { + continue; + } + if (seed.isLanguageSeed()) { + if (translated == null) { + translated = seed; + } + } else if (base == null) { + base = seed; + } + } + if (base == null || translated == null) { + return null; + } + String key = firstTranslatableKey(entity, base.getRows() + .get(0)); + if (key == null) { + return null; + } + Object baseId = base.getRows() + .get(0) + .get("id"); + Object baseValue = base.getRows() + .get(0) + .get(key); + Object translatedValue = null; + for (Map row : translated.getRows()) { + if (baseId != null && baseId.equals(row.get("id"))) { + translatedValue = row.get(key); + break; + } + } + if (baseValue == null || translatedValue == null) { + return null; + } + Map sample = new LinkedHashMap<>(); + sample.put("language", translated.getLanguage()); + sample.put("base", baseValue); + sample.put("translated", translatedValue); + return sample; + } + + private static String firstTranslatableKey(EntityIntent entity, Map row) { + for (FieldIntent field : entity.getFields()) { + if (!field.isPrimaryKey() && isStringType(field.getType()) && row.containsKey(field.getName())) { + return field.getName(); + } + } + return null; + } + + private static boolean hasSeed(IntentModel model, String entityName) { + for (SeedIntent seed : model.getSeeds()) { + if (entityName.equals(seed.getEntity()) && !seed.isLanguageSeed()) { + return true; + } + } + return false; + } + + private static List languages(IntentModel model) { + List languages = model.getLanguages(); + return (languages == null || languages.isEmpty()) ? List.of("en") : languages; + } + + /** The manifest's shared primary-key property name, taken from the model's PK column. */ + private static String idProperty(Map> edmEntities) { + for (Map entity : edmEntities.values()) { + Object properties = entity.get("properties"); + if (!(properties instanceof List list)) { + continue; + } + for (Object property : list) { + if (property instanceof Map map && "true".equals(String.valueOf(map.get("dataPrimaryKey")))) { + String name = String.valueOf(map.get("name")); + if (name != null && !name.isBlank()) { + return name; + } + } + } + } + return "Id"; + } + + /** The runner's layout token from the EDM layout type. */ + private static String layout(String layoutType) { + return switch (layoutType == null ? "" : layoutType) { + case "MANAGE_DOCUMENT" -> "document"; + default -> "manage-list"; + }; + } + + /** Maps an intent logical field type to the small set the runner's sample generator understands. */ + private static String logicalType(String type) { + return switch (type == null ? "string" : type) { + case "text", "uuid" -> "string"; + case "int", "long" -> "integer"; + case "double" -> "decimal"; + default -> type; + }; + } + + private static boolean isStringType(String type) { + return "string".equals(type) || "text".equals(type) || "uuid".equals(type); + } + + /** + * Reads back the {@code .model} written by the EDM generator and indexes its entities by name. + * Returns an empty map when the model is absent or unreadable (nothing to describe). + */ + @SuppressWarnings("unchecked") + private static Map> readModelEntities(IntentGenerationContext context, String baseName) { + Map> byName = new LinkedHashMap<>(); + try { + IRepository repository = context.getRepository(); + IResource resource = repository.getResource(context.getProjectRoot() + "/" + baseName + ".model"); + if (!resource.exists()) { + return byName; + } + String json = new String(resource.getContent(), StandardCharsets.UTF_8); + Map document = GSON.fromJson(json, Map.class); + Object modelNode = document.get("model"); + Map modelMap = (modelNode instanceof Map) ? (Map) modelNode : document; + Object entities = modelMap.get("entities"); + if (entities instanceof List list) { + for (Object entity : list) { + if (entity instanceof Map map) { + Object name = map.get("name"); + if (name != null) { + byName.put(String.valueOf(name), (Map) map); + } + } + } + } + } catch (RuntimeException e) { + LOGGER.warn("Failed to read the .model for the app-test manifest of [{}]; skipping", baseName, e); + } + return byName; + } + + /** Mirrors the template engine's {@code sanitizeJavaIdentifier} so REST paths match the backend. */ + private static String sanitizeJavaIdentifier(String name) { + if (name == null || name.isEmpty()) { + return "_"; + } + String sanitized = name.toLowerCase() + .replaceAll("[^a-z0-9_]", "_"); + return Character.isDigit(sanitized.charAt(0)) ? "_" + sanitized : sanitized; + } + + private static String string(Object value) { + return value == null ? "" : String.valueOf(value); + } + + private static String stringOr(Object value, String fallback) { + String string = string(value); + return string.isBlank() ? fallback : string; + } +} diff --git a/components/engine/engine-intent/src/test/java/org/eclipse/dirigible/components/intent/generator/apptest/AppTestIntentGeneratorTest.java b/components/engine/engine-intent/src/test/java/org/eclipse/dirigible/components/intent/generator/apptest/AppTestIntentGeneratorTest.java new file mode 100644 index 00000000000..2951226777e --- /dev/null +++ b/components/engine/engine-intent/src/test/java/org/eclipse/dirigible/components/intent/generator/apptest/AppTestIntentGeneratorTest.java @@ -0,0 +1,196 @@ +/* + * Copyright (c) 2010-2026 Eclipse Dirigible contributors + * + * All rights reserved. This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v2.0 which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v20.html + * + * SPDX-FileCopyrightText: Eclipse Dirigible contributors SPDX-License-Identifier: EPL-2.0 + */ +package org.eclipse.dirigible.components.intent.generator.apptest; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import org.eclipse.dirigible.components.intent.model.IntentModel; +import org.eclipse.dirigible.components.intent.parser.IntentParser; +import org.junit.jupiter.api.Test; + +/** + * Verifies the pure manifest-building core of {@link AppTestIntentGenerator}: module-level + * coordinates (module id, standalone shell, sanitized REST base, id property, languages) and the + * per-entity mapping (label/plural/layout/route/nav-group/api/table from the EDM metadata; fields + * and dropdown relations from the intent; cross-model relations and composition details excluded; + * the multilingual sample derived from inline seeds). Repository-free — it feeds a hand-built EDM + * metadata map, exactly the shape the {@code .model} carries. + */ +class AppTestIntentGeneratorTest { + + private static final String INTENT = """ + name: kf-mod-countries + languages: [en, bg] + entities: + - name: Country + kind: setting + group: master-data + multilingual: true + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + - { name: name, type: string, required: true, unique: true, length: 255 } + - { name: code2, type: string, required: true, unique: true, length: 2 } + - name: City + group: master-data + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + - { name: name, type: string, required: true, length: 200 } + relations: + - { name: Country, kind: manyToOne, to: Country, required: true } + seeds: + - name: countries + entity: Country + rows: + - { id: 1, name: Albania, code2: AL } + - name: countries-bg + entity: Country + language: bg + rows: + - { id: 1, name: "Албания", code2: AL } + """; + + private final IntentModel model = IntentParser.parse(INTENT); + + @SuppressWarnings("unchecked") + @Test + void buildsTheModuleLevelCoordinates() { + Map manifest = AppTestIntentGenerator.buildManifest("kf-mod-countries", "kf-mod-countries", model, edm()); + + assertEquals("kf-mod-countries", manifest.get("module")); + assertEquals("/services/web/kf-mod-countries/gen/kf-mod-countries/index.html", manifest.get("standaloneShell")); + // the REST base uses the sanitized (java-identifier) gen folder, not the raw hyphenated name + assertEquals("/services/java/kf-mod-countries/gen/kf_mod_countries/api", manifest.get("restBase")); + assertEquals("Id", manifest.get("idProperty")); + assertEquals(List.of("en", "bg"), manifest.get("languages")); + assertEquals(2, ((List) manifest.get("entities")).size()); + } + + @SuppressWarnings("unchecked") + @Test + void mapsEntityMetadataAndFields() { + Map country = + entity(AppTestIntentGenerator.buildManifest("kf-mod-countries", "kf-mod-countries", model, edm()), "Country"); + + assertEquals("Country", country.get("label")); + assertEquals("Countries", country.get("labelPlural")); + assertEquals("manage-list", country.get("layout")); + assertEquals("#/Country", country.get("route")); + assertEquals("master-data", country.get("navGroup")); + // api uses the sanitized perspective (Settings -> settings) + assertEquals("/settings/CountryController", country.get("api")); + assertEquals("KF_MOD_COUNTRIES_COUNTRY", country.get("table")); + assertEquals(Boolean.TRUE, country.get("multilingual")); + assertEquals(Boolean.TRUE, country.get("expectSeedData")); + + // fields: PascalCased names, required/unique/length flags, no PK + List> fields = (List>) country.get("fields"); + assertEquals(2, fields.size()); + Map name = fields.get(0); + assertEquals("Name", name.get("name")); + assertEquals("string", name.get("type")); + assertEquals(Boolean.TRUE, name.get("required")); + assertEquals(Boolean.TRUE, name.get("unique")); + assertEquals(255.0, ((Number) name.get("length")).doubleValue()); + assertEquals(Boolean.TRUE, name.get("major")); + } + + @SuppressWarnings("unchecked") + @Test + void derivesTheMultilingualSampleFromInlineSeeds() { + Map country = + entity(AppTestIntentGenerator.buildManifest("kf-mod-countries", "kf-mod-countries", model, edm()), "Country"); + Map sample = (Map) country.get("multilingualSample"); + assertNotNull(sample, "an inline base + bg seed pair yields a multilingual sample"); + assertEquals("bg", sample.get("language")); + assertEquals("Albania", sample.get("base")); + assertEquals("Албания", sample.get("translated")); + } + + @SuppressWarnings("unchecked") + @Test + void emitsToOneRelationsAsDropdowns() { + Map city = + entity(AppTestIntentGenerator.buildManifest("kf-mod-countries", "kf-mod-countries", model, edm()), "City"); + List> relations = (List>) city.get("relations"); + assertNotNull(relations); + assertEquals(1, relations.size()); + Map country = relations.get(0); + assertEquals("Country", country.get("name")); + assertEquals("manyToOne", country.get("kind")); + assertEquals("Country", country.get("to")); + assertEquals(Boolean.TRUE, country.get("required")); + assertEquals("dropdown", country.get("widget")); + assertEquals("Name", country.get("labelFrom")); + } + + @Test + void skipsProjectionAndDetailEntities() { + Map> edm = edm(); + edm.put("Extra", edmEntity("Extra", "Extra", "Extras", "MANAGE_DETAILS", "Extras", "master-data", "KF_MOD_COUNTRIES_EXTRA", false)); + // still only Country + City — the detail child is excluded + Map manifest = AppTestIntentGenerator.buildManifest("kf-mod-countries", "kf-mod-countries", model, edm); + List entities = (List) manifest.get("entities"); + assertEquals(2, entities.size()); + assertNull(entityOrNull(manifest, "Extra")); + } + + // ---- helpers: a minimal .model-shaped metadata map ------------------------------------------- + + private static Map> edm() { + Map> byName = new LinkedHashMap<>(); + byName.put("Country", + edmEntity("Country", "Country", "Countries", "MANAGE_MASTER", "Settings", "master-data", "KF_MOD_COUNTRIES_COUNTRY", true)); + byName.put("City", edmEntity("City", "City", "Cities", "MANAGE_MASTER", "Settings", "master-data", "KF_MOD_COUNTRIES_CITY", false)); + return byName; + } + + private static Map edmEntity(String name, String label, String plural, String layoutType, String perspective, + String navId, String table, boolean multilingual) { + Map entity = new LinkedHashMap<>(); + entity.put("name", name); + entity.put("entityLabel", label); + entity.put("menuLabel", plural); + entity.put("layoutType", layoutType); + entity.put("perspectiveName", perspective); + entity.put("perspectiveNavId", navId); + entity.put("dataName", table); + entity.put("multilingual", String.valueOf(multilingual)); + Map pk = new LinkedHashMap<>(); + pk.put("name", "Id"); + pk.put("dataPrimaryKey", "true"); + entity.put("properties", List.of(pk)); + return entity; + } + + @SuppressWarnings("unchecked") + private static Map entity(Map manifest, String name) { + Map found = entityOrNull(manifest, name); + assertTrue(found != null, "entity " + name + " present"); + return found; + } + + @SuppressWarnings("unchecked") + private static Map entityOrNull(Map manifest, String name) { + for (Object entity : (List) manifest.get("entities")) { + Map map = (Map) entity; + if (name.equals(map.get("name"))) { + return map; + } + } + return null; + } +} diff --git a/npm/test/LICENSE b/npm/test/LICENSE new file mode 100644 index 00000000000..e48e0963459 --- /dev/null +++ b/npm/test/LICENSE @@ -0,0 +1,277 @@ +Eclipse Public License - v 2.0 + + THE ACCOMPANYING PROGRAM IS PROVIDED UNDER THE TERMS OF THIS ECLIPSE + PUBLIC LICENSE ("AGREEMENT"). ANY USE, REPRODUCTION OR DISTRIBUTION + OF THE PROGRAM CONSTITUTES RECIPIENT'S ACCEPTANCE OF THIS AGREEMENT. + +1. DEFINITIONS + +"Contribution" means: + + a) in the case of the initial Contributor, the initial content + Distributed under this Agreement, and + + b) in the case of each subsequent Contributor: + i) changes to the Program, and + ii) additions to the Program; + where such changes and/or additions to the Program originate from + and are Distributed by that particular Contributor. A Contribution + "originates" from a Contributor if it was added to the Program by + such Contributor itself or anyone acting on such Contributor's behalf. + Contributions do not include changes or additions to the Program that + are not Modified Works. + +"Contributor" means any person or entity that Distributes the Program. + +"Licensed Patents" mean patent claims licensable by a Contributor which +are necessarily infringed by the use or sale of its Contribution alone +or when combined with the Program. + +"Program" means the Contributions Distributed in accordance with this +Agreement. + +"Recipient" means anyone who receives the Program under this Agreement +or any Secondary License (as applicable), including Contributors. + +"Derivative Works" shall mean any work, whether in Source Code or other +form, that is based on (or derived from) the Program and for which the +editorial revisions, annotations, elaborations, or other modifications +represent, as a whole, an original work of authorship. + +"Modified Works" shall mean any work in Source Code or other form that +results from an addition to, deletion from, or modification of the +contents of the Program, including, for purposes of clarity any new file +in Source Code form that contains any contents of the Program. Modified +Works shall not include works that contain only declarations, +interfaces, types, classes, structures, or files of the Program solely +in each case in order to link to, bind by name, or subclass the Program +or Modified Works thereof. + +"Distribute" means the acts of a) distributing or b) making available +in any manner that enables the transfer of a copy. + +"Source Code" means the form of a Program preferred for making +modifications, including but not limited to software source code, +documentation source, and configuration files. + +"Secondary License" means either the GNU General Public License, +Version 2.0, or any later versions of that license, including any +exceptions or additional permissions as identified by the initial +Contributor. + +2. GRANT OF RIGHTS + + a) Subject to the terms of this Agreement, each Contributor hereby + grants Recipient a non-exclusive, worldwide, royalty-free copyright + license to reproduce, prepare Derivative Works of, publicly display, + publicly perform, Distribute and sublicense the Contribution of such + Contributor, if any, and such Derivative Works. + + b) Subject to the terms of this Agreement, each Contributor hereby + grants Recipient a non-exclusive, worldwide, royalty-free patent + license under Licensed Patents to make, use, sell, offer to sell, + import and otherwise transfer the Contribution of such Contributor, + if any, in Source Code or other form. This patent license shall + apply to the combination of the Contribution and the Program if, at + the time the Contribution is added by the Contributor, such addition + of the Contribution causes such combination to be covered by the + Licensed Patents. The patent license shall not apply to any other + combinations which include the Contribution. No hardware per se is + licensed hereunder. + + c) Recipient understands that although each Contributor grants the + licenses to its Contributions set forth herein, no assurances are + provided by any Contributor that the Program does not infringe the + patent or other intellectual property rights of any other entity. + Each Contributor disclaims any liability to Recipient for claims + brought by any other entity based on infringement of intellectual + property rights or otherwise. As a condition to exercising the + rights and licenses granted hereunder, each Recipient hereby + assumes sole responsibility to secure any other intellectual + property rights needed, if any. For example, if a third party + patent license is required to allow Recipient to Distribute the + Program, it is Recipient's responsibility to acquire that license + before distributing the Program. + + d) Each Contributor represents that to its knowledge it has + sufficient copyright rights in its Contribution, if any, to grant + the copyright license set forth in this Agreement. + + e) Notwithstanding the terms of any Secondary License, no + Contributor makes additional grants to any Recipient (other than + those set forth in this Agreement) as a result of such Recipient's + receipt of the Program under the terms of a Secondary License + (if permitted under the terms of Section 3). + +3. REQUIREMENTS + +3.1 If a Contributor Distributes the Program in any form, then: + + a) the Program must also be made available as Source Code, in + accordance with section 3.2, and the Contributor must accompany + the Program with a statement that the Source Code for the Program + is available under this Agreement, and informs Recipients how to + obtain it in a reasonable manner on or through a medium customarily + used for software exchange; and + + b) the Contributor may Distribute the Program under a license + different than this Agreement, provided that such license: + i) effectively disclaims on behalf of all other Contributors all + warranties and conditions, express and implied, including + warranties or conditions of title and non-infringement, and + implied warranties or conditions of merchantability and fitness + for a particular purpose; + + ii) effectively excludes on behalf of all other Contributors all + liability for damages, including direct, indirect, special, + incidental and consequential damages, such as lost profits; + + iii) does not attempt to limit or alter the recipients' rights + in the Source Code under section 3.2; and + + iv) requires any subsequent distribution of the Program by any + party to be under a license that satisfies the requirements + of this section 3. + +3.2 When the Program is Distributed as Source Code: + + a) it must be made available under this Agreement, or if the + Program (i) is combined with other material in a separate file or + files made available under a Secondary License, and (ii) the initial + Contributor attached to the Source Code the notice described in + Exhibit A of this Agreement, then the Program may be made available + under the terms of such Secondary Licenses, and + + b) a copy of this Agreement must be included with each copy of + the Program. + +3.3 Contributors may not remove or alter any copyright, patent, +trademark, attribution notices, disclaimers of warranty, or limitations +of liability ("notices") contained within the Program from any copy of +the Program which they Distribute, provided that Contributors may add +their own appropriate notices. + +4. COMMERCIAL DISTRIBUTION + +Commercial distributors of software may accept certain responsibilities +with respect to end users, business partners and the like. While this +license is intended to facilitate the commercial use of the Program, +the Contributor who includes the Program in a commercial product +offering should do so in a manner which does not create potential +liability for other Contributors. Therefore, if a Contributor includes +the Program in a commercial product offering, such Contributor +("Commercial Contributor") hereby agrees to defend and indemnify every +other Contributor ("Indemnified Contributor") against any losses, +damages and costs (collectively "Losses") arising from claims, lawsuits +and other legal actions brought by a third party against the Indemnified +Contributor to the extent caused by the acts or omissions of such +Commercial Contributor in connection with its distribution of the Program +in a commercial product offering. The obligations in this section do not +apply to any claims or Losses relating to any actual or alleged +intellectual property infringement. In order to qualify, an Indemnified +Contributor must: a) promptly notify the Commercial Contributor in +writing of such claim, and b) allow the Commercial Contributor to control, +and cooperate with the Commercial Contributor in, the defense and any +related settlement negotiations. The Indemnified Contributor may +participate in any such claim at its own expense. + +For example, a Contributor might include the Program in a commercial +product offering, Product X. That Contributor is then a Commercial +Contributor. If that Commercial Contributor then makes performance +claims, or offers warranties related to Product X, those performance +claims and warranties are such Commercial Contributor's responsibility +alone. Under this section, the Commercial Contributor would have to +defend claims against the other Contributors related to those performance +claims and warranties, and if a court requires any other Contributor to +pay any damages as a result, the Commercial Contributor must pay +those damages. + +5. NO WARRANTY + +EXCEPT AS EXPRESSLY SET FORTH IN THIS AGREEMENT, AND TO THE EXTENT +PERMITTED BY APPLICABLE LAW, THE PROGRAM IS PROVIDED ON AN "AS IS" +BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, EITHER EXPRESS OR +IMPLIED INCLUDING, WITHOUT LIMITATION, ANY WARRANTIES OR CONDITIONS OF +TITLE, NON-INFRINGEMENT, MERCHANTABILITY OR FITNESS FOR A PARTICULAR +PURPOSE. Each Recipient is solely responsible for determining the +appropriateness of using and distributing the Program and assumes all +risks associated with its exercise of rights under this Agreement, +including but not limited to the risks and costs of program errors, +compliance with applicable laws, damage to or loss of data, programs +or equipment, and unavailability or interruption of operations. + +6. DISCLAIMER OF LIABILITY + +EXCEPT AS EXPRESSLY SET FORTH IN THIS AGREEMENT, AND TO THE EXTENT +PERMITTED BY APPLICABLE LAW, NEITHER RECIPIENT NOR ANY CONTRIBUTORS +SHALL HAVE ANY LIABILITY FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, +EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING WITHOUT LIMITATION LOST +PROFITS), HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +ARISING IN ANY WAY OUT OF THE USE OR DISTRIBUTION OF THE PROGRAM OR THE +EXERCISE OF ANY RIGHTS GRANTED HEREUNDER, EVEN IF ADVISED OF THE +POSSIBILITY OF SUCH DAMAGES. + +7. GENERAL + +If any provision of this Agreement is invalid or unenforceable under +applicable law, it shall not affect the validity or enforceability of +the remainder of the terms of this Agreement, and without further +action by the parties hereto, such provision shall be reformed to the +minimum extent necessary to make such provision valid and enforceable. + +If Recipient institutes patent litigation against any entity +(including a cross-claim or counterclaim in a lawsuit) alleging that the +Program itself (excluding combinations of the Program with other software +or hardware) infringes such Recipient's patent(s), then such Recipient's +rights granted under Section 2(b) shall terminate as of the date such +litigation is filed. + +All Recipient's rights under this Agreement shall terminate if it +fails to comply with any of the material terms or conditions of this +Agreement and does not cure such failure in a reasonable period of +time after becoming aware of such noncompliance. If all Recipient's +rights under this Agreement terminate, Recipient agrees to cease use +and distribution of the Program as soon as reasonably practicable. +However, Recipient's obligations under this Agreement and any licenses +granted by Recipient relating to the Program shall continue and survive. + +Everyone is permitted to copy and distribute copies of this Agreement, +but in order to avoid inconsistency the Agreement is copyrighted and +may only be modified in the following manner. The Agreement Steward +reserves the right to publish new versions (including revisions) of +this Agreement from time to time. No one other than the Agreement +Steward has the right to modify this Agreement. The Eclipse Foundation +is the initial Agreement Steward. The Eclipse Foundation may assign the +responsibility to serve as the Agreement Steward to a suitable separate +entity. Each new version of the Agreement will be given a distinguishing +version number. The Program (including Contributions) may always be +Distributed subject to the version of the Agreement under which it was +received. In addition, after a new version of the Agreement is published, +Contributor may elect to Distribute the Program (including its +Contributions) under the new version. + +Except as expressly stated in Sections 2(a) and 2(b) above, Recipient +receives no rights or licenses to the intellectual property of any +Contributor under this Agreement, whether expressly, by implication, +estoppel or otherwise. All rights in the Program not expressly granted +under this Agreement are reserved. Nothing in this Agreement is intended +to be enforceable by any entity that is not a Contributor or Recipient. +No third-party beneficiary rights are created under this Agreement. + +Exhibit A - Form of Secondary Licenses Notice + +"This Source Code may also be made available under the following +Secondary Licenses when the conditions for such availability set forth +in the Eclipse Public License, v. 2.0 are satisfied: {name license(s), +version(s), and exceptions or additional permissions here}." + + Simply including a copy of this Agreement, including this Exhibit A + is not sufficient to license the Source Code under Secondary Licenses. + + If it is not possible or desirable to put the notice in a particular + file, then You may include the notice in a location (such as a LICENSE + file in a relevant directory) where a recipient would be likely to + look for such a notice. + + You may add additional accurate notices of copyright ownership. diff --git a/npm/test/README.md b/npm/test/README.md new file mode 100644 index 00000000000..7a9e68e154a --- /dev/null +++ b/npm/test/README.md @@ -0,0 +1,62 @@ +# @aerokit/test + +Generic [Playwright](https://playwright.dev) runner that executes an intent module's generated +`.test` manifest against a running Eclipse Dirigible instance. The manifest (emitted by the +`AppTestIntentGenerator` in `engine-intent`, alongside `.edm`/`.model`/`.bpmn`/...) says WHAT the +app promises — entities, layouts, fields, seeds, languages; this package knows HOW to verify a +promise against the generated Harmonia UI and the reused REST controllers. So one runner, versioned +alongside the templates whose markup it drives, checks every generated app the same way. + +This is the app-integration-test surface (drives a browser + the REST layer from outside the +instance). It is distinct from `@aerokit/sdk/junit`, the in-instance server-side unit-test facade. + +## Use from a module + +A module keeps a tiny `test/` harness at its **repo root** (outside the published project folder, so +it never enters the registry or the npm package): + +```js +// test/app.spec.js +import { fileURLToPath } from 'node:url'; +import { runAppTest } from '@aerokit/test'; + +runAppTest(fileURLToPath(new URL('..//.test', import.meta.url))); +``` + +```json +// test/package.json +{ + "type": "module", + "scripts": { "test": "playwright test" }, + "devDependencies": { "@aerokit/test": "*", "@playwright/test": "^1.45" } +} +``` + +``` +BASE_URL=http://localhost:8080 npm test # defaults: admin/admin, installed Chrome +APPTEST_SHELL=1 npm test # also assert the shared-shell menu item +``` + +Env: `BASE_URL` (default `http://localhost:8080`), `APPTEST_USERNAME`/`APPTEST_PASSWORD` +(default `admin`/`admin`), `APPTEST_SHELL` (opt-in shared-shell flow). + +## Flows per entity + +- **list** — the plural title, a column header per major field, and (when `expectSeedData`) a row. +- **crud** — UI create → filter → row appears; edit via the detail pane → save; delete → confirm → gone. +- **rest** — the same CRUD over the generated Java controllers via `APIRequestContext` (isolates + backend vs UI failures), asserting the manifest's field names bind and delete yields 404. +- **multilingual** — switch the shared language key, reload, a seeded row shows its translated name. +- **shell** (opt-in) — the shared application shell's nav item opens the module SPA in its iframe. + +Test records carry an `APPTEST-` prefix and are removed in teardown; seed data is never mutated. + +## Custom UI hooks + +`runAppTest(manifest, { extend })`: + +- `widgets: { '': async (page, field, value) => ... }` — custom widget fillers. +- `entities: { : { skip: ['delete'], beforeCreate, afterCreate } }` — per-entity hooks. + +Hand-written specs live in `test/custom/*.spec.js` and import `@aerokit/test/fixtures` +(pre-logged-in `test`, an `api` client, `expect`) so login/baseURL/cleanup come free. diff --git a/npm/test/package.json b/npm/test/package.json new file mode 100644 index 00000000000..149e2e96d70 --- /dev/null +++ b/npm/test/package.json @@ -0,0 +1,30 @@ +{ + "name": "@aerokit/test", + "version": "0.1.0", + "description": "Generic Playwright runner that executes an intent module's generated .test manifest against a running Eclipse Dirigible instance", + "license": "EPL-2.0", + "type": "module", + "main": "src/index.js", + "exports": { + ".": "./src/index.js", + "./fixtures": "./src/fixtures.js" + }, + "files": [ + "src" + ], + "keywords": [ + "dirigible", + "playwright", + "integration-test", + "intent", + "low-code" + ], + "repository": { + "type": "git", + "url": "https://github.com/eclipse-dirigible/dirigible.git", + "directory": "npm/test" + }, + "peerDependencies": { + "@playwright/test": ">=1.45" + } +} diff --git a/npm/test/src/api.js b/npm/test/src/api.js new file mode 100644 index 00000000000..23264460305 --- /dev/null +++ b/npm/test/src/api.js @@ -0,0 +1,27 @@ +// Thin REST client over the generated Java controllers described by the manifest. +export function makeApi(request, manifest) { + const url = (entity, suffix = '') => manifest.restBase + entity.api + suffix; + + async function asJson(response) { + if (!response.ok()) { + throw new Error(`${response.request().method()} ${response.url()} -> ${response.status()} ${await response.text()}`); + } + const text = await response.text(); + return text ? JSON.parse(text) : undefined; + } + + return { + list: (entity, limit = 20) => request.get(url(entity, `?$limit=${limit}`)).then(asJson), + count: (entity) => request.get(url(entity, '/count')).then(asJson).then((body) => (typeof body === 'number' ? body : body.count)), + get: (entity, id) => request.get(url(entity, '/' + id)).then(asJson), + getResponse: (entity, id) => request.get(url(entity, '/' + id)), + create: (entity, data) => request.post(url(entity), { data }).then(asJson), + update: (entity, id, data) => request.put(url(entity, '/' + id), { data }).then(asJson), + remove: async (entity, id) => { + const response = await request.delete(url(entity, '/' + id)); + if (!response.ok()) { + throw new Error(`DELETE ${response.url()} -> ${response.status()} ${await response.text()}`); + } + }, + }; +} diff --git a/npm/test/src/env.js b/npm/test/src/env.js new file mode 100644 index 00000000000..6e610bc8b42 --- /dev/null +++ b/npm/test/src/env.js @@ -0,0 +1,3 @@ +export const BASE_URL = process.env.BASE_URL ?? 'http://localhost:8080'; +export const USERNAME = process.env.APPTEST_USERNAME ?? 'admin'; +export const PASSWORD = process.env.APPTEST_PASSWORD ?? 'admin'; diff --git a/npm/test/src/fixtures.js b/npm/test/src/fixtures.js new file mode 100644 index 00000000000..0e4308e3645 --- /dev/null +++ b/npm/test/src/fixtures.js @@ -0,0 +1,29 @@ +import { expect, test as base } from '@playwright/test'; +import { BASE_URL } from './env.js'; +import { login } from './session.js'; + +// Pre-authenticated Playwright test: +// - authState: worker-scoped form login, one session per worker +// - storageState: every page starts logged in +// - api: an APIRequestContext carrying the same session, for REST-level checks +export const test = base.extend({ + authState: [ + async ({ browser }, use) => { + await use(await login(browser)); + }, + { scope: 'worker' }, + ], + storageState: async ({ authState }, use) => { + await use(authState); + }, + api: async ({ playwright, authState }, use) => { + const request = await playwright.request.newContext({ + baseURL: BASE_URL, + storageState: authState, + }); + await use(request); + await request.dispose(); + }, +}); + +export { expect }; diff --git a/npm/test/src/flows/crud.js b/npm/test/src/flows/crud.js new file mode 100644 index 00000000000..1741713f5eb --- /dev/null +++ b/npm/test/src/flows/crud.js @@ -0,0 +1,63 @@ +import { expect, test } from '../fixtures.js'; +import { fillField, fillForm, resolveRelationSamples } from '../form.js'; +import { handleField, sampleRecord } from '../sample-values.js'; + +// Server-side per-column filter (the documented POST /search path). The handle field is +// the first major column, so its filter input is the first one in the filter row. +async function filterBy(page, value) { + const filter = page.getByPlaceholder('Filter…').first(); + await filter.fill(value); +} + +function dataRow(page, text) { + return page.locator('tbody tr', { hasText: text }); +} + +export function crudFlow(manifest, entity, opts = {}) { + const cfg = opts.extend?.entities?.[entity.name] ?? {}; + const skip = new Set(cfg.skip ?? []); + if (skip.has('crud')) return; + + test(`${entity.name}: create, edit and delete through the UI`, async ({ page, api }) => { + const record = sampleRecord(entity); + const handle = handleField(entity); + const relationSamples = await resolveRelationSamples(api, manifest, entity); + + // create + await page.goto(manifest.standaloneShell + entity.route); + await page.getByRole('button', { name: 'New' }).click(); + await expect(page).toHaveURL(/\/create$/); + await cfg.beforeCreate?.(page, record); + await fillForm(page, manifest, entity, record, relationSamples, opts); + await page.getByRole('button', { name: 'Create' }).click(); + await expect(page).toHaveURL(new RegExp(entity.route.replace(/[#/]/g, '\\$&') + '$')); + await filterBy(page, record[handle.name]); + await expect(dataRow(page, record[handle.name])).toHaveCount(1); + await cfg.afterCreate?.(page, record); + + // edit (via the detail pane the row selection reveals) + if (!skip.has('edit')) { + const updated = record[handle.name] + '-UPD'; + await dataRow(page, record[handle.name]).click(); + await page.getByRole('button', { name: 'Edit' }).first().click(); + await expect(page).toHaveURL(/\/edit$/); + await fillField(page, handle, updated, opts); + await page.getByRole('button', { name: 'Save' }).click(); + await expect(page).toHaveURL(new RegExp(entity.route.replace(/[#/]/g, '\\$&') + '$')); + await filterBy(page, updated); + await expect(dataRow(page, updated)).toHaveCount(1); + record[handle.name] = updated; + } + + // delete (detail pane trash button, then the confirm dialog) + if (!skip.has('delete')) { + await dataRow(page, record[handle.name]).click(); + await page.getByRole('button', { name: 'Delete', exact: true }).first().click(); + const dialog = page.locator('[x-h-dialog-overlay][data-open]'); + await dialog.getByRole('button', { name: 'Delete' }).click(); + await expect(dialog).toHaveCount(0); + await filterBy(page, record[handle.name]); + await expect(dataRow(page, record[handle.name])).toHaveCount(0); + } + }); +} diff --git a/npm/test/src/flows/list.js b/npm/test/src/flows/list.js new file mode 100644 index 00000000000..58ed70ab6b8 --- /dev/null +++ b/npm/test/src/flows/list.js @@ -0,0 +1,17 @@ +import { expect, test } from '../fixtures.js'; +import { labelOf } from '../sample-values.js'; + +// The list page renders: plural title in the toolbar, one column header per major +// field, and (when seed data is expected) at least one data row. +export function listFlow(manifest, entity) { + test(`${entity.name}: list page renders the declared columns`, async ({ page }) => { + await page.goto(manifest.standaloneShell + entity.route); + await expect(page.locator('[x-h-toolbar-title]', { hasText: entity.labelPlural }).first()).toBeVisible(); + for (const field of (entity.fields ?? []).filter((f) => f.major !== false && !f.primaryKey)) { + await expect(page.getByRole('columnheader').filter({ hasText: labelOf(field) }).first()).toBeVisible(); + } + if (entity.expectSeedData) { + await expect(page.locator('tbody tr:visible').first()).toBeVisible(); + } + }); +} diff --git a/npm/test/src/flows/multilingual.js b/npm/test/src/flows/multilingual.js new file mode 100644 index 00000000000..7a5b9be095a --- /dev/null +++ b/npm/test/src/flows/multilingual.js @@ -0,0 +1,20 @@ +import { expect, test } from '../fixtures.js'; + +// The read-time translation overlay: switch the shared language key (what the Region & +// Language setting writes), reload, and a known seed row shows its translated name. +// Needs a concrete sample in the manifest: { language, base, translated }. +export function multilingualFlow(manifest, entity) { + const sample = entity.multilingualSample; + if (!entity.multilingual || !sample) return; + + test(`${entity.name}: ${sample.language} translation overlays on read`, async ({ page }) => { + await page.goto(manifest.standaloneShell + entity.route); + await expect(page.locator('[x-h-toolbar-title]', { hasText: entity.labelPlural }).first()).toBeVisible(); + await page.evaluate((lang) => localStorage.setItem('codbex.harmonia.language', lang), sample.language); + await page.reload(); + await expect(page.locator('tbody tr', { hasText: sample.translated }).first()).toBeVisible(); + await page.evaluate(() => localStorage.setItem('codbex.harmonia.language', 'en')); + await page.reload(); + await expect(page.locator('tbody tr', { hasText: sample.base }).first()).toBeVisible(); + }); +} diff --git a/npm/test/src/flows/rest.js b/npm/test/src/flows/rest.js new file mode 100644 index 00000000000..ed15f387123 --- /dev/null +++ b/npm/test/src/flows/rest.js @@ -0,0 +1,40 @@ +import { makeApi } from '../api.js'; +import { expect, test } from '../fixtures.js'; +import { handleField, sampleRecord } from '../sample-values.js'; + +// The same contract over the generated REST controllers, no browser: isolates backend +// failures from UI failures and verifies the manifest's field names bind. +export function restFlow(manifest, entity, opts = {}) { + const cfg = opts.extend?.entities?.[entity.name] ?? {}; + if (new Set(cfg.skip ?? []).has('rest')) return; + const idProperty = manifest.idProperty ?? 'Id'; + + test(`${entity.name}: REST create, read, update and delete`, async ({ api }) => { + const client = makeApi(api, manifest); + const payload = sampleRecord(entity); + const handle = handleField(entity); + for (const relation of entity.relations ?? []) { + const target = manifest.entities.find((e) => e.name === relation.to); + const rows = await client.list(target, 1); + expect(rows.length, `${relation.to} must have at least one row`).toBeGreaterThan(0); + payload[relation.name] = rows[0][idProperty]; + } + + const created = await client.create(entity, payload); + const id = created?.[idProperty]; + expect(id, 'create response carries the generated id').toBeTruthy(); + try { + const read = await client.get(entity, id); + expect(read[handle.name]).toBe(payload[handle.name]); + + const updatedValue = payload[handle.name] + '-UPD'; + await client.update(entity, id, { ...read, [handle.name]: updatedValue }); + const reread = await client.get(entity, id); + expect(reread[handle.name]).toBe(updatedValue); + } finally { + await client.remove(entity, id); + } + const gone = await client.getResponse(entity, id); + expect(gone.status()).toBe(404); + }); +} diff --git a/npm/test/src/flows/shell.js b/npm/test/src/flows/shell.js new file mode 100644 index 00000000000..6225e5f1956 --- /dev/null +++ b/npm/test/src/flows/shell.js @@ -0,0 +1,16 @@ +import { expect, test } from '../fixtures.js'; + +// Shared application shell (/services/web/application/): the entity's menu item exists +// under its nav group and opens the module SPA in the embedded iframe. Requires the +// navigation module that defines the groups, so it is opt-in: APPTEST_SHELL=1. +export function shellFlow(manifest, entity) { + test(`${entity.name}: shared shell menu item opens the module`, async ({ page }) => { + test.skip(!process.env.APPTEST_SHELL, 'APPTEST_SHELL=1 enables the shared shell flow'); + await page.goto('/services/web/application/'); + const item = page.getByText(entity.labelPlural, { exact: true }).first(); + await expect(item).toBeVisible(); + await item.click(); + const app = page.frameLocator('iframe').first(); + await expect(app.getByText(entity.labelPlural, { exact: true }).first()).toBeVisible(); + }); +} diff --git a/npm/test/src/form.js b/npm/test/src/form.js new file mode 100644 index 00000000000..97f1a0624c9 --- /dev/null +++ b/npm/test/src/form.js @@ -0,0 +1,44 @@ +import { makeApi } from './api.js'; +import { editableFields, handleField } from './sample-values.js'; + +// Generated form controls: every field input has id="f_"; a to-one relation is a +// Harmonia x-h-select whose input holds the value and whose options carry the label. +export async function fillField(page, field, value, opts = {}) { + const custom = opts.extend?.widgets?.[field.widget]; + if (custom) return custom(page, field, value); + const input = page.locator('#f_' + field.name); + if (field.type === 'boolean') return input.setChecked(!!value); + await input.fill(String(value)); +} + +// The x-h-select directive hides its input and builds a span[role=combobox] trigger +// labelled by the field label; options carry role=option. +export async function pickDropdown(page, relation, optionText) { + await page.getByRole('combobox', { name: relation.label ?? relation.name }).click(); + await page.getByRole('option', { name: optionText }).first().click(); +} + +// Resolve a live option for each to-one relation: take the first existing row of the +// target entity and use its label field's value as the visible option text. +export async function resolveRelationSamples(request, manifest, entity) { + const api = makeApi(request, manifest); + const samples = []; + for (const relation of entity.relations ?? []) { + const target = manifest.entities.find((e) => e.name === relation.to); + if (!target) throw new Error(`Relation ${entity.name}.${relation.name}: target ${relation.to} not in manifest`); + const rows = await api.list(target, 1); + if (!rows?.length) throw new Error(`Relation ${entity.name}.${relation.name}: no ${relation.to} rows to pick from`); + const labelFrom = relation.labelFrom ?? handleField(target).name; + samples.push({ + relation, + id: rows[0][manifest.idProperty ?? 'Id'], + label: rows[0][labelFrom], + }); + } + return samples; +} + +export async function fillForm(page, manifest, entity, record, relationSamples, opts = {}) { + for (const field of editableFields(entity)) await fillField(page, field, record[field.name], opts); + for (const sample of relationSamples) await pickDropdown(page, sample.relation, sample.label); +} diff --git a/npm/test/src/index.js b/npm/test/src/index.js new file mode 100644 index 00000000000..387fb1c4b4e --- /dev/null +++ b/npm/test/src/index.js @@ -0,0 +1,23 @@ +import fs from 'node:fs'; +import { test } from './fixtures.js'; +import { crudFlow } from './flows/crud.js'; +import { listFlow } from './flows/list.js'; +import { multilingualFlow } from './flows/multilingual.js'; +import { restFlow } from './flows/rest.js'; +import { shellFlow } from './flows/shell.js'; + +// Entry point: execute a module's generated .test manifest. Accepts the parsed +// manifest object or a path to the file. opts.extend hosts the custom-UI hooks (widget +// fillers, per-entity skip lists, before/afterCreate) - see kf-catalog PROPOSAL_APPTEST.md. +export function runAppTest(manifestRef, opts = {}) { + const manifest = typeof manifestRef === 'string' ? JSON.parse(fs.readFileSync(manifestRef, 'utf8')) : manifestRef; + for (const entity of manifest.entities ?? []) { + test.describe(`${manifest.module} / ${entity.name}`, () => { + listFlow(manifest, entity, opts); + crudFlow(manifest, entity, opts); + restFlow(manifest, entity, opts); + multilingualFlow(manifest, entity, opts); + shellFlow(manifest, entity, opts); + }); + } +} diff --git a/npm/test/src/sample-values.js b/npm/test/src/sample-values.js new file mode 100644 index 00000000000..1c341ba3178 --- /dev/null +++ b/npm/test/src/sample-values.js @@ -0,0 +1,60 @@ +const ALPHA = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'; +const DIGITS = '0123456789'; + +function rand(chars, n) { + let out = ''; + for (let i = 0; i < n; i++) out += chars[Math.floor(Math.random() * chars.length)]; + return out; +} + +// Type-aware sample value for one field. Long strings carry the APPTEST- prefix (the +// cleanup marker and the searchable handle); short strings always contain a digit so +// they can never collide with letter-only nomenclature seeds (ISO codes etc.). +export function sampleValue(field) { + switch (field.type) { + case 'string': { + const len = field.length ?? 64; + if (len >= 16) return 'APPTEST-' + rand(ALPHA + DIGITS, 6); + return rand(ALPHA, Math.max(1, len - 1)) + rand(DIGITS, 1); + } + case 'integer': + case 'bigint': + return 7; + case 'decimal': + case 'double': + return 3.14; + case 'boolean': + return true; + case 'date': + return '2026-07-08'; + case 'timestamp': + case 'datetime': + return '2026-07-08T10:00'; + default: + return 'APPTEST-' + rand(ALPHA + DIGITS, 6); + } +} + +export function editableFields(entity) { + return (entity.fields ?? []).filter((f) => !f.readOnly && !f.primaryKey && !f.generated); +} + +// One sample record for the entity's own fields (relations are resolved separately, +// against live target rows). +export function sampleRecord(entity) { + const record = {}; + for (const field of editableFields(entity)) record[field.name] = sampleValue(field); + return record; +} + +// The searchable "handle" field: the first long string field shown in the list. Its +// value identifies the record in the table across the create/edit/delete flow. +export function handleField(entity) { + const field = editableFields(entity).find((f) => f.type === 'string' && (f.length ?? 64) >= 16 && f.major !== false); + if (!field) throw new Error(`Entity ${entity.name} has no string handle field for UI flows`); + return field; +} + +export function labelOf(field) { + return field.label ?? field.name; +} diff --git a/npm/test/src/session.js b/npm/test/src/session.js new file mode 100644 index 00000000000..a41ce3b7d09 --- /dev/null +++ b/npm/test/src/session.js @@ -0,0 +1,19 @@ +import { BASE_URL, PASSWORD, USERNAME } from './env.js'; + +// Dirigible uses form login (basic auth is not accepted by the login flow). Logs in once +// per worker and returns a storageState (session cookie) reused by every test and by the +// REST client. +export async function login(browser) { + const context = await browser.newContext({ baseURL: BASE_URL }); + const page = await context.newPage(); + await page.goto('/login'); + await page.fill('input[name="username"]', USERNAME); + await page.fill('input[name="password"]', PASSWORD); + await Promise.all([ + page.waitForURL((url) => !url.pathname.includes('/login')), + page.click('button[type="submit"]'), + ]); + const state = await context.storageState(); + await context.close(); + return state; +} diff --git a/tests/tests-integrations/src/main/java/org/eclipse/dirigible/integration/tests/api/IntentEngineIT.java b/tests/tests-integrations/src/main/java/org/eclipse/dirigible/integration/tests/api/IntentEngineIT.java index b73c7623920..c85b450cf3d 100644 --- a/tests/tests-integrations/src/main/java/org/eclipse/dirigible/integration/tests/api/IntentEngineIT.java +++ b/tests/tests-integrations/src/main/java/org/eclipse/dirigible/integration/tests/api/IntentEngineIT.java @@ -378,7 +378,7 @@ void generate_writes_all_model_files_into_the_workspace_project() { hasItems("orders.edm", "orders.model", "OrderApproval.bpmn", "ApproveOrder.form", "OrdersByCustomer.report", "OrderBalance.report", "orders.roles", "orders.glue", "countries.csvim", "countries.csv", - "doc/Templates/Order/Print/en/standard.print")) + "doc/Templates/Order/Print/en/standard.print", "orders.test")) .body("scrubbed", hasSize(0)) // The model-to-code plan the editor replays: one entry per generated model with a // recipe in .settings, naming the template + parameters. @@ -401,6 +401,7 @@ void generate_writes_all_model_files_into_the_workspace_project() { assertSeeds(); assertGlue(); assertSettings(); + assertAppTestManifest(); } @Test @@ -1754,6 +1755,28 @@ private void assertGlue() { && glue.contains("\"countField\": \"OrderCount\""), "glue should carry the customerOrderCount rollup listeners"); } + private void assertAppTestManifest() { + assertTrue(resource("orders.test").exists(), "the .test app-test manifest should be generated"); + String manifest = contentOf("orders.test"); + // module-level coordinates: module id + the sanitized REST base + standalone shell + id property + assertTrue(manifest.contains("\"module\": \"orders\""), "the manifest names the module"); + assertTrue(manifest.contains("\"restBase\": \"/services/java/" + PROJECT + "/gen/orders/api\""), + "the manifest carries the sanitized REST base"); + assertTrue(manifest.contains("\"standaloneShell\": \"/services/web/" + PROJECT + "/gen/orders/index.html\""), + "the manifest carries the standalone shell URL"); + assertTrue(manifest.contains("\"idProperty\": \"Id\""), "the manifest carries the id property"); + // the document master renders as a document layout; the composition detail child is excluded + assertTrue(manifest.contains("\"name\": \"Order\"") && manifest.contains("\"layout\": \"document\""), + "the Order document master should be a document layout"); + assertFalse(manifest.contains("\"name\": \"OrderItem\""), "the composition detail child should be excluded"); + // a plain entity is a manage-list with its controller + route + assertTrue(manifest.contains("\"name\": \"Customer\"") && manifest.contains("CustomerController") + && manifest.contains("\"#/Customer\""), "the Customer entity should carry its controller api and route"); + // the multilingual setting entity is flagged + assertTrue(manifest.contains("\"name\": \"Country\"") && manifest.contains("\"multilingual\": true"), + "the multilingual Country entity should be flagged"); + } + private void assertSettings() { assertTrue(resource("orders.settings").exists(), "the .settings file should be scaffolded"); String settings = contentOf("orders.settings");