Repository: mint-o-badges/oeb-test
Pull request #13: Feat/adjust badge creation to use stepper
Author: login: YouQam
Created: 2025-03-07T11:20:05Z | Merged: 2025-04-25T13:50:51Z



Diff:
diff --git a/.nvmrc b/.nvmrc
new file mode 100644
index 0000000..517f386
--- /dev/null
+++ b/.nvmrc
@@ -0,0 +1 @@
+v22.14.0
diff --git a/.vscode/launch.json b/.vscode/launch.json
new file mode 100644
index 0000000..b2a2cf7
--- /dev/null
+++ b/.vscode/launch.json
@@ -0,0 +1,19 @@
+{
+    "version": "0.2.0",
+    "configurations": [
+      {
+        "name": "Mocha (Test single file)",
+        "type": "node",
+        "request": "launch",
+        "program": "${workspaceFolder}/node_modules/mocha/bin/_mocha",
+        "outFiles": ["${workspaceFolder}/test/**/*.js"],
+        "runtimeArgs": ["--nolazy"],
+        "args": [
+          "--inspect-brk", 
+          "${relativeFile}",
+        ],
+        "console": "integratedTerminal",
+        "internalConsoleOptions": "neverOpen",
+      },
+    ]
+}
\ No newline at end of file
diff --git a/README.md b/README.md
index ca1098e..77331d3 100644
--- a/README.md
+++ b/README.md
@@ -1,4 +1,5 @@
 # Open Educational Badges Test
+
 This is a selenium project that aims to implement end-to-end tests for [Open Educational Badges](https://openbadges.education).
 
 There are two ways to run this application:
@@ -6,9 +7,11 @@ There are two ways to run this application:
 ## Docker
 
 You need to have `docker compose` installed. Then simply run
+
 ```bash
 docker compose run node
 ```
+
 You can observe the automated browser in the VNC under `localhost:7900`.
 The password is `secret` by default.
 
@@ -16,19 +19,24 @@ The password is `secret` by default.
 
 ## No docker
 
-To run, first set up an up-to-date npm (at the time of writing v22.8.0), e.g. by running:
+To run, first make sure you are using the correct version of node.
+If you use [nvm](https://github.com/nvm-sh/nvm), run:
+
 ```bash
-echo "node" > .nvmrc
-nvm install
 nvm use
+nvm install
 ```
 
+Otherwise install the version of node found in the [.nvmrc](.nvmrc) file.
+
 Then install the dependencies via
+
 ```bash
 npm install
 ```
 
 You also need to [GraphicsMagick](https://github.com/yakovmeister/pdf2image/blob/HEAD/docs/gm-installation.md) for being able to convert the QR pdf to an image. For this, install `ghostscript` and `graphicsmagick` via your systems package manager, e.g.:
+
 ```bash
 sudo apt-get update
 sudo apt-get install ghostscript
@@ -36,6 +44,7 @@ sudo apt-get install graphicsmagick
 ```
 
 Then you need to set up some credentials to be able to log in in the staging environment. For this, clone the example and adjust the values as needed:
+
 ```bash
 cp example.secret.js secret.js
 ```
@@ -43,6 +52,7 @@ cp example.secret.js secret.js
 Alternatively you can modify the URL in `config.js` to something you have credentials to (e.g. your local environment).
 
 Finally, run the tests with
+
 ```bash
 npx mocha test
 ```
@@ -50,6 +60,7 @@ npx mocha test
 Note that it might be necessary that **the browser window stays focuesed**.
 
 To run a specific test, use
+
 ```bash
 npx mocha test/<test>.spec.js
 # E.g.:
diff --git a/config.js b/config.js
index 855ce28..87a45f5 100644
--- a/config.js
+++ b/config.js
@@ -1,5 +1,5 @@
-export const url = "https://staging.openbadges.education";
-export const backendUrl = "https://api.staging.openbadges.education";
+export const url = "https://develop.openbadges.education";
+export const backendUrl = "https://api.develop.openbadges.education";
 export const defaultWait = 12000;    // waits for a specified amount of time for a certain condition to be true. 
 export const implicitWait = 2000;   // waits for a specified amount of time before throwing a no such element exception. 
 export const extendedWait = 25000; 
diff --git a/package-lock.json b/package-lock.json
index f7a828b..7ff0526 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,5 +1,5 @@
 {
-    "name": "project",
+    "name": "oeb-test",
     "lockfileVersion": 3,
     "requires": true,
     "packages": {
diff --git a/test/badge.js b/test/badge.js
index 4cf731f..e20261e 100644
--- a/test/badge.js
+++ b/test/badge.js
@@ -3,6 +3,7 @@ import {
     until,
     Condition
 } from 'selenium-webdriver';
+import { Actions } from 'selenium-webdriver/lib/input.js';
 import assert from 'assert';
 import {username, password} from '../secret.js';
 import {url, defaultWait, extendedWait} from '../config.js';
@@ -27,8 +28,11 @@ const testDuration = '42';
 const testImagePath = 'assets/image.png';
 const testAwardName = 'automated test name';
 const nounProjectSearchText = 'test';
-const aiCompetenciesDescriptionText = 'With solid computer skills, you can automate routine tasks, analyze data more effectively, and communicate with colleagues more efficiently.'
-const tagName = 'automated test tag'
+const aiCompetenciesDescriptionText = 'With solid computer skills, you can automate routine tasks, analyze data more effectively, and communicate with colleagues more efficiently.';
+const tagName = 'automated test tag';
+const microDegreeTitle = 'automated micro degree';
+const microDegreeDescription = 'automated micro degree description';
+
 
 /**
  * This requires that there exists a verified issuer for the user associated with the configured credentials
@@ -40,38 +44,23 @@ export async function navigateToBadgeCreation(driver) {
     // wait here as well
     const expectedTitle = 'Issuers - Open Educational Badges';
     driver.wait(until.titleIs(expectedTitle), defaultWait);
-    // The rendering process of the issuers page is quite weird,
-    // so we wait for that to finish.
-    // For that we take the first card (and hope that it's a valid
-    // issuer) and wait until it has all three buttons
-    const card = await driver.wait(until.elementLocated(
-        ExtendedBy.containingText(
-            By.css('div.tw-border-purple.tw-grow'),
-            By.tagName('span'),
-            // Search for "Lernpfad erstellen" because only
-            // verified issuers have this button
-            'Lernpfad erstellen')),
-        defaultWait,
-        "Couldn't find card");
-    const condition = new Condition("for issuers to have fully loaded",
-        async driver => {
-            const children = await card.findElements(By.tagName('oeb-button'));
-            return children.length === 3;
-        });
-    await driver.wait(condition, defaultWait,
-        "Issuer loading didn't complete");
 
-    await driver.wait(until.elementLocated(
+    const createBadgeButton = await driver.wait(until.elementLocated(
         By.css("[id^='create-new-badge-btn']:not(.disabled)")),
         defaultWait);
-    const createBadgeButton = await card.findElement(
-        By.css("[id^='create-new-badge-btn']:not(.disabled)"));
     await createBadgeButton.click();
 
-    await driver.wait(until.titleIs('Create Badge - Open Educational Badges'), extendedWait);
+    await driver.wait(until.titleIs('Badge erstellen - Open Educational Badges'), extendedWait);
 }
 
-export async function navigateToBadgeDetails(driver) {
+/**
+ * Navigates to the badge details of the badge created during automation.
+ * If multiple badges are created, use {@link skip} to skip an entry and choose
+ * the next available badge instead.
+ * @param {import('selenium-webdriver').ThenableWebDriver} driver 
+ * @param {number} skip Number of badges to skip before selecting the badge to navigate to
+ */
+export async function navigateToBadgeDetails(driver, skip = 0) {
     // This ensures that the same issuer is used as for the created badge
     await navigateToBadgeCreation(driver);
 
@@ -87,22 +76,56 @@ export async function navigateToBadgeDetails(driver) {
         By.css('span.tw-text-oebblack')), defaultWait);
     const spans = Array.from(await driver.findElements(
         By.css('span.tw-text-oebblack')));
+    let toSkip = skip;
     for (const span of spans) {
         const text = await span.getText();
         if (text === testBadgeTitle) {
-            span.click();
-            break;
+            if(toSkip === 0) {
+                span.click();
+                break;
+            }
+            toSkip--;
         }
     }
 
     await driver.wait(until.titleIs(`Badge Class - ${testBadgeTitle} - Open Educational Badges`), defaultWait);
 }
 
+export async function navigateToMicroDegreeDetails(driver) {
+    // This ensures that the same issuer is used as for the created badge
+    await navigateToBadgeCreation(driver);
+
+    await driver.wait(until.elementLocated(By.css('span.breadcrumbs-x-text')), defaultWait);
+    const breadcrumbs = await driver.findElements(By.css(
+        'span.breadcrumbs-x-text'));
+    const issuerBreadcrumb = breadcrumbs[1];
+    issuerBreadcrumb.click();
+
+    await driver.wait(until.titleMatches(/Issuer - .* - Open Educational Badges/), defaultWait);
+
+    await driver.wait(until.elementLocated(
+        By.css('hlm-tabs-list')), defaultWait);
+    const tabs = await driver.findElements(
+        By.css('hlm-tabs-list > button'));
+    await tabs[1].click();
+
+    // For the load
+    await driver.wait(until.elementsLocated(By.css("learningpaths-datatable")));
+
+    const receivedMicroDegreeLink = await driver.findElement(
+        ExtendedBy.tagWithText("span", microDegreeTitle));
+    await receivedMicroDegreeLink.click();
+}
+
 /**
- * Expects the badge to have been created already
+ * Navigates to the badge awarding page, expects the badge to have been created already.
+ * If multiple badges are created during testing, use {@link skip} to skip
+ * over entries and choose the next badge to navigate to.
+ * @param {import('selenium-webdriver').ThenableWebDriver} driver 
+ * @param {number} skip Number of badges to skip before selecting the one to navigate to
  */
-export async function navigateToBadgeAwarding(driver) {
-    await navigateToBadgeDetails(driver);
+export async function navigateToBadgeAwarding(driver, skip = 0) {
+    await navigateToBadgeDetails(driver, skip);
 
     const badgeAwardButton = await driver.wait(until.elementLocated(
         ExtendedBy.submitButtonWithText('Badge direkt vergeben')),
@@ -126,62 +149,82 @@ export async function navigateToBackpack(driver) {
 
 export async function navigateToReceivedBadge(driver) {
     await navigateToBackpack(driver);
-    await driver.wait(until.elementLocated(By.linkText(testBadgeTitle)), defaultWait);
-    const receivedBadgeLink = await driver.findElement(By.linkText(testBadgeTitle));
+    const receivedBadgeLink = await driver.wait(until.elementLocated(By.linkText(testBadgeTitle)), defaultWait);
     receivedBadgeLink.click();
 
     await driver.wait(until.titleIs(`Backpack - ${testBadgeTitle} - Open Educational Badges`), defaultWait);
 }
 
+export async function navigateToReceivedMicroDegree(driver) {
+    // this switches to the micro degree tab of the backpack
+    const tabs = await driver.findElements(By.css("hlm-tabs-list > button"));
+    await tabs[2].click();
+
+    // For the load
+    await driver.wait(until.elementsLocated(By.css("bg-learningpathcard")));
+
+    const receivedMicroDegreeLink = await driver.findElement(
+        ExtendedBy.tagWithText("span", microDegreeTitle));
+    await receivedMicroDegreeLink.click();
+
+    await driver.wait(until.titleIs(`LearningPath - Open Educational Badges`), defaultWait);
+}
+
 /**
  * This assumes that the driver already navigated to the badge creation page
  */
-export async function createBadge(driver, badgeType = 'Teilnahme') {
-    const categoryDropdownButton = await driver.findElement(By.css(
-        'button[role="combobox"]'));
-    await categoryDropdownButton.click();
-
-    // Category selection 
-    await driver.wait(until.elementLocated(
-        ExtendedBy.tagWithText('hlm-option', badgeType)), defaultWait);
-    const participationOption = await driver.findElement(
-        ExtendedBy.tagWithText('hlm-option', badgeType));
-    await participationOption.click();
-
-    // Description field
-    const shortDescriptionField = await driver.findElement(By.css(
-        'textarea'));
-    await shortDescriptionField.sendKeys(testBadgeDescription);
+export async function createBadge(driver, badgeType = 'participation') {
+    // Initial step: Badge type selection
+    const selectedBadgeType = await driver.wait(
+        until.elementLocated(By.css(`[href*='${badgeType}']`)), 
+        defaultWait);
+    await selectedBadgeType.click();
 
+    // Next step: Badge details
+    // Title field
+    const titleField = await driver.findElement(By.css(
+        'input[type="text"]'));
+    await titleField.sendKeys(testBadgeTitle);
     // Duration field
     const durationField = await driver.findElement(By.css(
         'input[type="number"]'));
     await durationField.clear()
     await durationField.sendKeys(testDuration);
-
-    // Title field
-    const titleField = await driver.findElement(By.css(
-        'input[type="text"]'));
-    await titleField.sendKeys(testBadgeTitle);
-
+    // Description field
+     const shortDescriptionField = await driver.findElement(By.css(
+        'textarea'));
+    await shortDescriptionField.sendKeys(testBadgeDescription);
     // Image field
     // Testing switching between framed and unframed/owned images is essential as users might experience some issues while doing so
     // 1. Upload own image (insterted into badge frame)
-    uploadImage(driver, "image_field0", testImagePath);
+    await uploadImage(driver, "image_field", 1, testImagePath);
     // 2. Upload own image
-    await uploadImage(driver, "image_field1", testImagePath);
+    await uploadImage(driver, "image_field", 0, testImagePath);
     // 3. Select an image from nounproject
     await selectNounProjectImage(driver, nounProjectSearchText);
 
-    // * Badge with skills - only with competency badge type
-    if(badgeType == 'Kompetenz'){
+    // Click next button to move to the next step
+    const nextButton = await driver.findElement(
+        ExtendedBy.tagWithText('span', 'Weiter'));
+    await nextButton.click();
+
+    // Next step: Add skills - only with competency badge type
+    if(badgeType == 'competency'){
         // Add competencies using AI
         await addCompetenciesViaAI(driver, aiCompetenciesDescriptionText);
         // Add competencies by hand
         await addCompetenciesByHand(driver);
+
+        // Click next button to move to the next step
+        await nextButton.click();
     }
+
+    // Next step: Add new tag
+    await addNewTag(driver, tagName);
+    // Click next button to move to the next step
+    await nextButton.click();
     
-    // * Optional Badge-Details
+    // Final step: add optional details then submit badge
     await addOptionalDetails(driver);
 
     const submitButton = await driver.findElement(By.id('create-badge-btn'));
@@ -194,9 +237,6 @@ async function addOptionalDetails(driver){
     // Open optional badge-detail section
     const optionalDetailSection = await driver.findElement(By.id('optional-details'));
     await optionalDetailSection.click();
-
-    // Add new tag
-    await addNewTag(driver, tagName);
     // Link badge to educational standards
     await linkToEduStandards(driver);
     // Badge validity
@@ -235,12 +275,23 @@ export async function receiveBadge(driver) {
     assert.equal(receivedBadges.length, 1, "Expected to have received one badge with the specified title");
 }
 
+/**
+ * This assumes that the driver already navigated to the backpack page
+ */
+export async function receiveMicroDegreeBadge(driver) {
+    // This checks if the micro degree badge appears in the backpack
+    await driver.wait(until.elementLocated(By.linkText(microDegreeTitle)), defaultWait);
+    const receivedBadges = await driver.findElements(By.linkText(
+        microDegreeTitle));
+    assert.equal(receivedBadges.length, 1, "Expected to have received one micro degree badge with the specified title");
+}
+
 /**
  * This assumes that the driver already navigated to the received badge page
  */
 export async function downloadPdfFromBackpack(driver) {
-    const moreSvgButton = await driver.findElement(By.css(
-        'svg[icon="icon_more"]'));
+    const moreSvgButton = await driver.wait(until.elementLocated(By.css(
+        'svg[icon="icon_more"]')), defaultWait);
     await moreSvgButton.click();
 
     const dropdownButtons = await driver.findElements(By.css(
@@ -256,7 +307,21 @@ export async function downloadPdfFromBackpack(driver) {
     await driver.wait(until.elementIsEnabled(downloadButton), defaultWait);
     await downloadButton.click();
 
-    await waitForDownload(driver, new RegExp(`^${testBadgeTitle} - \\d+\\.pdf$`));
+    await waitForDownload(driver, new RegExp(/^\d{4}-\d{2}-\d{2}-[a-zA-Z0-9_ ]+\.pdf$/));
+    // TODO: Verify file content
+    clearDownloadDirectory();
+}
+
+/**
+ * This assumes that the driver already navigated to the received micro degree
+ */
+export async function downloadMicroDegree(driver) {
+    const downloadPdfButton = await driver.findElement(
+        By.css("oeb-button[variant='secondary']")
+    );
+    await downloadPdfButton.click();
+
+    await waitForDownload(driver, new RegExp(/^\d{4}-\d{2}-\d{2}-[a-zA-Z0-9_ ]+\.pdf$/), defaultWait);
     // TODO: Verify file content
     clearDownloadDirectory();
 }
@@ -273,7 +338,7 @@ export async function downloadPdfFromIssuer(driver) {
     assert.equal(certificateButtons.length, 1, "Only expected one assertion and thus one certificate");
     await certificateButtons[0].click();
 
-    await waitForDownload(driver, new RegExp(`^${testBadgeTitle} - \\d+\\.pdf$`));
+    await waitForDownload(driver, new RegExp(/^\d{4}-\d{2}-\d{2}-[a-zA-Z0-9_ ]+\.pdf$/));
     // TODO: Verify file content
     clearDownloadDirectory();
 }
@@ -313,21 +378,19 @@ export async function waitForDownload(driver, regex, timeout = 5000) {
  * This assumes that the driver already navigated to the badge detail page
  */
 export async function revokeBadge(driver) {
-    await driver.wait(until.elementLocated(
+    const revokeButton = await driver.wait(until.elementLocated(
         ExtendedBy.submitButtonWithText('zurücknehmen')),
         defaultWait);
-    const revokeButton = await driver.findElement(
-        ExtendedBy.submitButtonWithText('zurücknehmen'));
     await revokeButton.click();
 
-    const confirmDialog = await driver.findElement(By.tagName('confirm-dialog'));
+    const confirmDialog = await driver.findElement(By.css('confirm-dialog'));
     const confirmButton = await confirmDialog.findElement(By.css(
         'button.button:not(.button-secondary)'));
     await confirmButton.click();
 
-    const issuerDatatable = await driver.findElement(By.tagName(
+    const issuerDatatable = await driver.findElement(By.css(
         'issuer-detail-datatable'));
-    const heading = await issuerDatatable.findElement(By.tagName('h3'));
+    const heading = await issuerDatatable.findElement(By.css('h3'));
     await driver.wait(until.elementTextIs(heading, '0 Badge Empfänger:innen'), defaultWait);
 }
 
@@ -340,10 +403,38 @@ export async function confirmRevokedBadge(driver) {
     assert.equal(receivedBadges.length, 0, "Expected to have received no badge with the specified title");
 }
 
-export async function deleteBadgeOverApi() {
+/**
+ * Revokes a micro degree from the micro degrees details page.
+ * This assumes that the driver already navigated to the micro degree detail page.
+ * @param {import('selenium-webdriver').ThenableWebDriver} driver 
+ */
+export async function revokeMicroDegree(driver) {
+    const revokeButton = await driver.wait(until.elementLocated(
+        ExtendedBy.submitButtonWithText('zurücknehmen')),
+        defaultWait);
+    await revokeButton.click();
+
+    const confirmDialog = await driver.findElement(By.css('confirm-dialog'));
+    const confirmButton = await confirmDialog.findElement(By.css(
+        'button.button:not(.button-secondary)'));
+    await confirmButton.click();
+
+    await driver.wait(until.elementLocated(ExtendedBy.tagWithText("h3", "0 Micro Degree-Empfänger:innen")), defaultWait);
+}
+
+/**
+ * This assumes that the driver already navigated to the backpack page
+ */
+export async function confirmRevokedMicroDegree(driver) {
+    const receivedBadges = await driver.findElements(By.linkText(
+        microDegreeTitle));
+    assert.equal(receivedBadges.length, 0, "Expected to have received no micro degree with the specified title");
+}
+
+export async function deleteBadgeOverApi(name = testBadgeTitle) {
     const apiToken = await requestToken(username, password);
     assert(apiToken, "Failed to request an API token");
-    const badge = await findBadge(apiToken, testBadgeTitle);
+    const badge = await findBadge(apiToken, name);
     assert(badge, "Failed to find the badge");
     const assertions = await findAssertions(apiToken, badge.entityId);
     const revokationResult = await revokeAssertions(apiToken, assertions);
@@ -363,7 +454,7 @@ export async function validateParticipationBadge(driver) {
     const descriptionHeading = await driver.findElement(
         ExtendedBy.tagWithText('h3', 'Kurzbeschreibung'));
     const descriptionElement = await driver.findElement(
-        ExtendedBy.sibling(descriptionHeading, By.tagName('p')));
+        ExtendedBy.sibling(descriptionHeading, By.css('p')));
     const descriptionText = await descriptionElement.getText();
     assert.equal(descriptionText, testBadgeDescription);
 
@@ -373,7 +464,7 @@ export async function validateParticipationBadge(driver) {
     const categoryHeading = await driver.findElement(
         ExtendedBy.tagWithText('dt', 'Kategorie'));
     const categoryElement = await driver.findElement(
-        ExtendedBy.sibling(categoryHeading, By.tagName('dd')));
+        ExtendedBy.sibling(categoryHeading, By.css('dd')));
     const categoryText = await categoryElement.getText();
     assert.equal(categoryText, 'Teilnahme-Badge');
 
@@ -386,18 +477,18 @@ export async function validateParticipationBadge(driver) {
     const lastEditedHeading = await driver.findElement(
         ExtendedBy.tagWithText('dt', 'Zuletzt editiert'));
     const lastEditedElement = await driver.findElement(
-        ExtendedBy.sibling(lastEditedHeading, By.tagName('dd')));
+        ExtendedBy.sibling(lastEditedHeading, By.css('dd')));
     const lastEditedTime = await lastEditedElement.findElement(
-        By.tagName('time'));
+        By.css('time'));
     const lastEditedText = await lastEditedTime.getText();
     assert.equal(lastEditedText, todayString);
 
     const createdHeading = await driver.findElement(
         ExtendedBy.tagWithText('dt', 'Erstellt am'));
     const createdElement = await driver.findElement(
-        ExtendedBy.sibling(createdHeading, By.tagName('dd')));
+        ExtendedBy.sibling(createdHeading, By.css('dd')));
     const createdTime = await createdElement.findElement(
-        By.tagName('time'));
+        By.css('time'));
     const createdText = await createdTime.getText();
     assert.equal(createdText, todayString);
 }
@@ -411,7 +502,7 @@ export async function validateBadge(driver, badgeType = 'Teilnahme') {
     const descriptionHeading = await driver.findElement(
         ExtendedBy.tagWithText('h3', 'Kurzbeschreibung'));
     const descriptionElement = await driver.findElement(
-        ExtendedBy.sibling(descriptionHeading, By.tagName('p')));
+        ExtendedBy.sibling(descriptionHeading, By.css('p')));
     const descriptionText = await descriptionElement.getText();
     assert.equal(descriptionText, testBadgeDescription);
 
@@ -421,7 +512,7 @@ export async function validateBadge(driver, badgeType = 'Teilnahme') {
     const categoryHeading = await driver.findElement(
         ExtendedBy.tagWithText('dt', 'Kategorie'));
     const categoryElement = await driver.findElement(
-        ExtendedBy.sibling(categoryHeading, By.tagName('dd')));
+        ExtendedBy.sibling(categoryHeading, By.css('dd')));
     const categoryText = await categoryElement.getText();
     assert.equal(categoryText, `${badgeType}-Badge`);
 
@@ -434,24 +525,24 @@ export async function validateBadge(driver, badgeType = 'Teilnahme') {
     const lastEditedHeading = await driver.findElement(
         ExtendedBy.tagWithText('dt', 'Zuletzt editiert'));
     const lastEditedElement = await driver.findElement(
-        ExtendedBy.sibling(lastEditedHeading, By.tagName('dd')));
+        ExtendedBy.sibling(lastEditedHeading, By.css('dd')));
     const lastEditedTime = await lastEditedElement.findElement(
-        By.tagName('time'));
+        By.css('time'));
     const lastEditedText = await lastEditedTime.getText();
     assert.equal(lastEditedText, todayString);
 
     const createdHeading = await driver.findElement(
         ExtendedBy.tagWithText('dt', 'Erstellt am'));
     const createdElement = await driver.findElement(
-        ExtendedBy.sibling(createdHeading, By.tagName('dd')));
+        ExtendedBy.sibling(createdHeading, By.css('dd')));
     const createdTime = await createdElement.findElement(
-        By.tagName('time'));
+        By.css('time'));
     const createdText = await createdTime.getText();
     assert.equal(createdText, todayString);
 
     if(badgeType == 'Kompetenz'){
         const BadgeCompetencies = await driver.findElements(By.css('competency-accordion'))
-        assert.equal(BadgeCompetencies.length, 3);
+        assert.equal(BadgeCompetencies.length, 2);
     }
 }
 
@@ -470,3 +561,101 @@ export async function verifyBadgeOverApi() {
     // Study-load is in minutes while test-duration is in hours
     assert.equal(studyLoad/60, testDuration);
 }
+
+/**
+ * Creates {@link n} number of  badges
+ * @param {import('selenium-webdriver').ThenableWebDriver} driver 
+ * @param {number} n Number of badges to create
+ */
+export async function createBadges(driver, n) {
+    for(let i = 0; i < n; i++) {
+        await navigateToBadgeCreation(driver);
+        await createBadge(driver);
+    }
+}
+
+/**
+ * Creates a micro degree that includes the first {@link n} badges
+ * available to the logged in user
+ * @param {import('selenium-webdriver').ThenableWebDriver} driver 
+ * @param {number} n Number of badges to include
+ */
+export async function createMicroDegree(driver, n) {
+    // Initial step: Badge type selection
+    const selectedBadgeType = await driver.wait(until.elementLocated(By.css('[href*="learningpaths/create"]')), defaultWait);
+    await selectedBadgeType.click();
+
+    // Next step: Badge details
+    // Title field
+    const titleField = await driver.findElement(By.css(
+        'input[type="text"]'));
+    await titleField.sendKeys(microDegreeTitle);
+
+    // Description
+    const descriptionField = await driver.findElement(By.css(
+        'textarea'));
+    await descriptionField.sendKeys(microDegreeDescription);
+    
+    // Image field
+    // Testing switching between framed and unframed/owned images is essential as users might experience some issues while doing so
+    // 1. Upload own image (insterted into badge frame)
+    await uploadImage(driver, "image_field", 1, testImagePath);
+    // 2. Upload own image
+    await uploadImage(driver, "image_field", 0, testImagePath);
+    // 3. Select an image from nounproject
+    await selectNounProjectImage(driver, nounProjectSearchText);
+
+    // Click next button to move to the next step
+    const nextButton = await driver.findElement(
+        ExtendedBy.tagWithText('span', 'Weiter'));
+    await nextButton.click();
+
+    // Next step: Add badges to the micro degree
+    const selectableCards = await driver.findElements(
+        ExtendedBy.containingText(
+            By.css('bg-badgecard'),
+            By.css('a'),
+            testBadgeTitle
+        ));
+
+    for(let i = 0; i < n; i++)
+        await selectableCards[i].findElement(By.css('hlm-checkbox')).click();
+
+    // Next step: Order of the badges
+    await nextButton.click();
+    const badgeCards = await driver.findElements(By.css(
+        'bg-badgecard'
+    ));
+    const dragAndDropAction = new Actions(driver)
+        .dragAndDrop(badgeCards[0], badgeCards.at(-1));
+    await dragAndDropAction.perform();
+
+    // Next step: Tag and Create
+    await nextButton.click();
+    await addNewTag(driver, tagName);   
+    
+    const submitForm = await driver.findElement(By.css('form'));
+    await submitForm.submit();
+
+    // The regular expression is for urls of the micro degree like 
+    // '/issuer/issuers/{issuerID}/learningpaths/{learningpathID}'
+    // but it disallows the path where the micro degree is created:
+    // '/issuer/issuers/{issuerID}/learningpaths/create'
+    await driver.wait(until.urlMatches(/\/issuer\/issuers\/[^\/]+\/learningpaths\/(?!create$)[^\/]+$/), extendedWait);
+}
+
+/**
+ * Deletes the micro degree using api requests without UI
+ */
+export const deleteMicroDegreeOverApi =
+    () => deleteBadgeOverApi(microDegreeTitle);
+
+/**
+ * Deletes the given number of badges via the api, essentially
+ * calling {@link deleteBadgeOverApi} multiple times.
+ * @param {number} n The number of badges to delete
+ */
+export async function deleteBadgesOverApi(n) {
+    for(let i = 0; i < n; i++)
+        await deleteBadgeOverApi();
+}
\ No newline at end of file
diff --git a/test/badge.spec.js b/test/badge.spec.js
index 9f39656..38523fe 100644
--- a/test/badge.spec.js
+++ b/test/badge.spec.js
@@ -19,14 +19,33 @@ import {
     revokeBadge,
     confirmRevokedBadge,
     deleteBadgeOverApi,
-    validateParticipationBadge,
     verifyBadgeOverApi,
     validateBadge,
-    clearDownloadDirectory
+    clearDownloadDirectory,
+    createBadges,
+    deleteBadgesOverApi,
+    createMicroDegree,
+    deleteMicroDegreeOverApi,
+    receiveMicroDegreeBadge,
+    confirmRevokedMicroDegree,
+    downloadMicroDegree,
+    navigateToReceivedMicroDegree,
+    navigateToMicroDegreeDetails,
+    revokeMicroDegree
 } from './badge.js';
 
+/** Global timeout for all tests if not specified otherwise */
+const GLOBAL_TIMEOUT_MS = 30_000;
+
+/** Number of badges to create for micro degree related tests */
+const BADGES_FOR_MICRO_DEGREE = 3;
+
+/** Extra timeout granted per badge for micro degree tests */
+const EXTRA_TIMEOUT_PER_BADGE_MS = 10_000;
+
+
 describe('Badge Test', function() {
-    this.timeout(30000);
+    this.timeout(GLOBAL_TIMEOUT_MS);
     let driver;
 
     before(async () => {
@@ -69,12 +88,12 @@ describe('Badge Test', function() {
 
     it('delete the participation badge', async function() {
         await deleteBadgeOverApi();
-    })
+    });
 
     // Competency badge
     it('should create a competency badge', async function() {
         await navigateToBadgeCreation(driver);
-        await createBadge(driver, 'Kompetenz');
+        await createBadge(driver, 'competency');
     });
 
     it('should validate the competency badge', async function() {
@@ -112,7 +131,65 @@ describe('Badge Test', function() {
 
     it('delete the competency badge', async function() {
         await deleteBadgeOverApi();
+    });
+
+    // micro degree
+    it('should create a micro degree', async () => {
+        // We need at least three badges to put as part of the degree
+        await createBadges(driver, BADGES_FOR_MICRO_DEGREE);
+        await navigateToBadgeCreation(driver);
+        await createMicroDegree(driver, BADGES_FOR_MICRO_DEGREE);
+    })
+    .timeout(this.timeout() + BADGES_FOR_MICRO_DEGREE * EXTRA_TIMEOUT_PER_BADGE_MS); // Allow larger timeout since a number of badges have to be created
+
+    it('should award the micro degree', async function() {
+        // users get micro degrees automatically by receiving all badges
+        // contained in the degree itself
+        for(let i = 0; i < BADGES_FOR_MICRO_DEGREE; i++) {
+            await navigateToBadgeAwarding(driver, i);
+            await awardBadge(driver);
+        }
     })
+    .timeout(this.timeout() + BADGES_FOR_MICRO_DEGREE * EXTRA_TIMEOUT_PER_BADGE_MS); // Allow larger timeout since awarding badges takes a while
+
+    it('should receive the micro degree', async function() {
+        await navigateToBackpack(driver);
+        await receiveMicroDegreeBadge(driver);
+    });
+
+    it('should download the micro degree pdf from the backpack', async function() {
+        await navigateToBackpack(driver);
+        await navigateToReceivedMicroDegree(driver);
+        // https://github.com/mint-o-badges/badgr-ui/issues/1231
+        // A delay is required here due to a race condition in the application.
+        // If removed, the download button will be clicked but won't trigger a download.
+        await new Promise(resolve => setTimeout(resolve, 1_000));
+        await downloadMicroDegree(driver);
+    });
+
+    it('should download the micro degree pdf from the internal issuer page', async function() {
+        await navigateToMicroDegreeDetails(driver);
+        await downloadPdfFromIssuer(driver);
+    });
+
+    it('should revoke the micro degree', async function() {
+        for(let i = 0; i < BADGES_FOR_MICRO_DEGREE; i++)
+        {
+            await navigateToBadgeDetails(driver, i);
+            await revokeBadge(driver);
+        }
+        await navigateToMicroDegreeDetails(driver);
+        await revokeMicroDegree(driver);
+
+        await navigateToBackpack(driver);
+        await confirmRevokedMicroDegree(driver);
+    })
+    .timeout(this.timeout() + BADGES_FOR_MICRO_DEGREE * EXTRA_TIMEOUT_PER_BADGE_MS); // Allow larger timeout since there is a number of badges to revoke
+
+    it('delete the micro degree', async function() {
+        await deleteMicroDegreeOverApi();
+        await deleteBadgesOverApi(BADGES_FOR_MICRO_DEGREE);
+    });
 
     afterEach(async function () {
         await screenshot(driver, this.currentTest);
diff --git a/test/issuer.js b/test/issuer.js
index 2b48c4b..d1fb97c 100644
--- a/test/issuer.js
+++ b/test/issuer.js
@@ -5,6 +5,7 @@ import {url, defaultWait} from '../config.js';
 import {requestToken, findIssuer, deleteIssuer} from '../util/api.js';
 import {ExtendedBy} from '../util/selection.js';
 import path from 'path';
+import { uploadImage } from '../util/image-upload.js';
 
 const testIssuerName = 'automatedTestName';
 const testIssuerImagePath = 'assets/image.png';
@@ -26,14 +27,7 @@ export async function navigateToIssuerCreation(driver) {
  * This assumes that the driver already navigated to the issuer creation page
  */
 export async function createIssuer(driver) {
-    const imageField = await driver.findElement(By.id(
-        'image_field0'));
-
-    const image = path.resolve(testIssuerImagePath);
-    await imageField.sendKeys(image);
-
-    await driver.wait(until.elementLocated(By.css(
-        'img[src^="data:image/png;base64,iVBORw0KGg"]')));
+    await uploadImage(driver, "image_field", 0, testIssuerImagePath);
 
     const textFields = await driver.findElements(By.css(
         'input[type="text"]'));
@@ -57,7 +51,7 @@ export async function createIssuer(driver) {
         ExtendedBy.tagWithText('hlm-option', username));
     await mailOption.click();
 
-    const description = await driver.findElement(By.tagName(
+    const description = await driver.findElement(By.css(
         'textarea'));
     await description.sendKeys(testIssuerDescription);
 
@@ -65,22 +59,22 @@ export async function createIssuer(driver) {
     await categoryDropdownButton.click();
 
     const categoryOption = await driver.findElement(By.css(
-        'hlm-option#cdk-option-2'));
+        'hlm-option#brn-option-2'));
     await categoryOption.click();
 
     const streetField = textFields[1];
     await streetField.sendKeys(testIssuerStreet);
 
+    const streetnumberField = textFields[2];
+    await streetnumberField.sendKeys(testIssuerStreetnumber);
+
     const numberFields = await driver.findElements(By.css(
         'input[type="number"]'));
 
-    const streetnumberField = numberFields[0];
-    await streetnumberField.sendKeys(testIssuerStreetnumber);
-
-    const postalCodeField = numberFields[1];
+    const postalCodeField = numberFields[0];
     await postalCodeField.sendKeys(testIssuerPostalCode);
 
-    const cityField = textFields[2];
+    const cityField = textFields[3];
     await cityField.sendKeys(testIssuerCity);
 
     const confirmCheckbox = await driver.findElement(By.css
diff --git a/test/login.js b/test/login.js
index b13f92a..641490c 100644
--- a/test/login.js
+++ b/test/login.js
@@ -48,11 +48,11 @@ export async function acceptTerms(driver) {
     }
     
     const checkbox = await termsBox.findElement(
-        By.tagName('hlm-checkbox-checkicon'));
+        By.css('hlm-checkbox-checkicon'));
     await checkbox.click();
 
     const submitButton = await termsBox.findElement(
-        By.tagName('button'));
+        By.css('button'));
     await submitButton.click();
 
     await driver.get(`${url}/issuer`);
diff --git a/test/qr.js b/test/qr.js
index 9d65db1..e0c1419 100644
--- a/test/qr.js
+++ b/test/qr.js
@@ -39,12 +39,12 @@ export async function navigateToQrCreation(driver, name = 'automated test title'
 export async function generateQrCode(driver) {
     const titelOebInput = await driver.findElement(By.css(
         'oeb-input[label="Titel"]'));
-    const titelField = await titelOebInput.findElement(By.tagName('input'));
+    const titelField = await titelOebInput.findElement(By.css('input'));
     await titelField.sendKeys('automated test QR title');
 
     const nameOebInput = await driver.findElement(By.css(
         'oeb-input[label="Name Ersteller:in"]'));
-    const nameField = await nameOebInput.findElement(By.tagName('input'));
+    const nameField = await nameOebInput.findElement(By.css('input'));
     await nameField.sendKeys('automated test name');
 
     // TODO: Validity fields
@@ -124,23 +124,23 @@ export async function requestBadgeViaQr(driver) {
         'oeb-input[label="Vorname"]')), defaultWait);
     const vornameOebInput = await driver.findElement(By.css(
         'oeb-input[label="Vorname"]'));
-    const vornameField = await vornameOebInput.findElement(By.tagName(
+    const vornameField = await vornameOebInput.findElement(By.css(
         'input'));
     await vornameField.sendKeys('automatedName');
 
     const nachnameOebInput = await driver.findElement(By.css(
         'oeb-input[label="Nachname"]'));
-    const nachnameField = await nachnameOebInput.findElement(By.tagName(
+    const nachnameField = await nachnameOebInput.findElement(By.css(
         'input'));
     await nachnameField.sendKeys('automatedSurname');
 
     const emailOebInput = await driver.findElement(By.css(
         'oeb-input[label="E-Mail"]'));
-    const emailField = await emailOebInput.findElement(By.tagName(
+    const emailField = await emailOebInput.findElement(By.css(
         'input'));
     await emailField.sendKeys(username);
 
-    const ageConfirmationCheckbox = await driver.findElement(By.tagName(
+    const ageConfirmationCheckbox = await driver.findElement(By.css(
         'brn-checkbox'));
     await ageConfirmationCheckbox.click();
 
diff --git a/test/signup.js b/test/signup.js
index 9c8ee0d..98825ac 100644
--- a/test/signup.js
+++ b/test/signup.js
@@ -44,7 +44,7 @@ export async function signup(driver) {
     const passwordRepeatField = passwordFields[1];
     await passwordRepeatField.sendKeys(testUserPassword);
 
-    const checkboxes = await driver.findElements(By.tagName(
+    const checkboxes = await driver.findElements(By.css(
         'hlm-checkbox-checkicon'));
     const termsCheckbox = checkboxes[0];
     await termsCheckbox.click();
diff --git a/util/badge-helper.js b/util/badge-helper.js
index 39ae183..3e95b4d 100644
--- a/util/badge-helper.js
+++ b/util/badge-helper.js
@@ -4,8 +4,8 @@ import {ExtendedBy} from './selection.js';
 import {setOEBInputValueById, setOEBInputValueByCSS } from './components.js';
 
 export async function addNewTag(driver, tagName ){
-    await driver.wait(until.elementLocated(By.css('input[placeholder="Neuer Tag..."]')), defaultWait);
-    const tagField = await driver.findElement(By.css('input[placeholder="Neuer Tag..."]'));
+    await driver.wait(until.elementLocated(By.css('input[placeholder^="Einen Tag eingeben"]')), defaultWait);
+    const tagField = await driver.findElement(By.css('input[placeholder^="Einen Tag eingeben"]'));
     await tagField.sendKeys(tagName);
     // Clicking the button is harder then pushing enter, since the click is intercepted,
     // if the tag is new
@@ -55,7 +55,14 @@ export async function addCompetenciesByHand(driver){
     const competenciesByHandSection = await driver.findElement(By.id(
       'competencies-by-hand-section'));
     await competenciesByHandSection.click();
+
+    const addOwnCompetencyButton = await driver.findElement(By.css(
+        'oeb-button[icon="lucidePlus"]'));
+    await addOwnCompetencyButton.click();
+
+
     setOEBInputValueById(driver, "competencyTitle_0", "competency title");
+    setOEBInputValueById(driver, "competencyDescriptionInput_0", "competency description", "textarea");
     setOEBInputValueById(driver, "competencyDurationHour_0", 2);
     setOEBInputValueById(driver, "competencyDurationMinutes_0", 30);
     const competencyCategoryDropdownButton = await driver.findElement(By.id(
@@ -66,8 +73,6 @@ export async function addCompetenciesByHand(driver){
     const skillOption = await driver.findElement(
         ExtendedBy.tagWithText('hlm-option', "Fähigkeit"));
     await skillOption.click();
-    setOEBInputValueById(driver, "competencyDescriptionInput_0", "competency description", "textarea");
-    setOEBInputValueById(driver, "escoIdentifierInput_0", "test/skill/0000-0000-0000-0000-0000");
 }
 
 export async function addCompetenciesViaAI(driver, aiCompetenciesDescriptionText){
@@ -78,11 +83,11 @@ export async function addCompetenciesViaAI(driver, aiCompetenciesDescriptionText
         'suggest-competencies-btn'));
     await suggestCompetenciesButton.click();
     
-    // Select first and third skills
+    // Only use first skill to always ensure we have only one resulting in a deterministic
+    // total number of competencies
+    await driver.wait(until.elementLocated(By.id('checkboxAiSkill_0')), defaultWait);
     const firstAISkillCheckbox = await driver.findElement(By.id(
         'checkboxAiSkill_0'));
     firstAISkillCheckbox.click();
-    const thirdAISkillCheckbox = await driver.findElement(By.id(
-        'checkboxAiSkill_2'));
-    thirdAISkillCheckbox.click();
+    await driver.wait(until.elementIsEnabled(firstAISkillCheckbox), defaultWait);
 }
diff --git a/util/components.js b/util/components.js
index dbdc21c..339d894 100644
--- a/util/components.js
+++ b/util/components.js
@@ -2,7 +2,7 @@ import {By} from "selenium-webdriver";
 
 export async function setOEBInputValueById(driver, id, value, fieldType='input') {
     const oebInputParent = await driver.findElement(By.id(id));
-    const oebInputChild = await oebInputParent.findElement(By.tagName(fieldType)
+    const oebInputChild = await oebInputParent.findElement(By.css(fieldType)
     );
     await oebInputChild.clear()
     await oebInputChild.sendKeys(value);
@@ -13,7 +13,7 @@ export async function setOEBInputValueByCSS(driver, cssSelector, value) {
     const oebInputParent = await driver.findElement(
       By.css(`oeb-input[label=${cssSelector}]`)
     );
-    const oebInputChild = await oebInputParent.findElement(By.tagName("input"));
+    const oebInputChild = await oebInputParent.findElement(By.css("input"));
     await oebInputChild.clear()
     await oebInputChild.sendKeys(value);
 }
\ No newline at end of file
diff --git a/util/image-upload.js b/util/image-upload.js
index 212c0e1..3bf3b7b 100644
--- a/util/image-upload.js
+++ b/util/image-upload.js
@@ -1,9 +1,21 @@
 import { By, until } from "selenium-webdriver";
 import { defaultWait } from "../config.js";
 import path from "path";
+import { ExtendedBy } from "./selection.js";
 
-export async function uploadImage(driver, element_id, imagePath) {
-    const imageField = await driver.findElement(By.id(element_id));
+/**
+ * Uploads the image at {@link imagePath} to the {@link n}th element whose id starts with {@link element_id}
+ * @param {any} driver 
+ * @param {string} element_id Id of the element to upload the image to
+ * @param {number} nthElement Number of the element with the given id
+ * @param {string} imagePath Path to the image file to upload
+ */
+export async function uploadImage(driver, element_id, nthElement, imagePath) {
+    let imageField;
+    if(nthElement === 0)
+        imageField = await driver.findElement(By.css(`[id^='${element_id}']`));
+    else
+        imageField = (await driver.findElements(By.css(`[id^='${element_id}']`)))[nthElement];
     const image = path.resolve(imagePath);
     await imageField.sendKeys(image);
     await driver.wait(
@@ -16,18 +28,12 @@ export async function uploadImage(driver, element_id, imagePath) {
 
 export async function selectNounProjectImage(driver, searchText) {
     // Open noun-project dialog
-    // Try 5 times to avoid stale reference errors
-    for (let i = 0; i < 5; i++) {
-        try {
-            const nounProject_option = await driver.wait(
-                until.elementLocated(By.id("nounProject_span")),
-                defaultWait
-            );
-            await nounProject_option.click();
-            break;
-        } catch(e) {
-        }
-    }
+    const nounProject_option = await driver.wait(
+        until.elementLocated(ExtendedBy.tagWithText("button", "anderes Icon suchen")),
+        defaultWait
+    );
+    await nounProject_option.click();
+
     // Search for an image
     const searchIconField = await driver.wait(
         until.elementLocated(By.id("forminput")),
diff --git a/util/selection.js b/util/selection.js
index fb92205..009564a 100644
--- a/util/selection.js
+++ b/util/selection.js
@@ -49,13 +49,13 @@ export class ExtendedBy {
      */
     static submitButtonWithText(text, textTag = 'span') {
         return ExtendedBy.containingText(
-            By.css('button[type="submit"]'), By.tagName('span'),
+            By.css('button[type="submit"]'), By.css('span'),
             text);
     }
 
     static tagWithText(tag, text, trim = true) {
         return (async (driver) => {
-            const selected = await driver.findElements(By.tagName(tag));
+            const selected = await driver.findElements(By.css(tag));
             const selectedArray = Array.from(selected);
             const mappedPromises = selectedArray.map(async node => {
                 try {


Reviews:
user: login: timber-they
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-15T11:06:19Z
user: login: scheidtdav
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-15T13:55:04Z
user: login: scheidtdav
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-15T14:02:41Z
user: login: scheidtdav
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-15T14:04:36Z
user: login: scheidtdav
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-15T14:08:31Z
user: login: scheidtdav
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-15T14:11:57Z
user: login: scheidtdav
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-15T14:16:33Z
user: login: scheidtdav
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-15T14:17:07Z
user: login: scheidtdav
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-15T14:19:40Z
user: login: scheidtdav
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-16T08:40:02Z
user: login: scheidtdav
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-16T08:44:47Z
user: login: scheidtdav
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-16T08:58:35Z
user: login: timber-they
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-17T12:10:13Z
user: login: timber-they
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-17T12:15:53Z
user: login: timber-they
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-17T12:17:20Z
user: login: timber-they
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-17T12:20:00Z
user: login: scheidtdav
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-22T07:34:22Z
user: login: scheidtdav
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-22T07:38:44Z
user: login: scheidtdav
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-22T07:42:04Z
user: login: timber-they
body: I find it a bit unlucky that instead of first fixing the changes I requested, you included the implementation of a whole different feature into this PR. If you hadn't done this, I could probably now merge this PR
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-23T12:35:39Z
user: login: scheidtdav
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-23T13:15:00Z
user: login: scheidtdav
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-23T13:17:51Z
user: login: scheidtdav
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-23T13:46:26Z
user: login: scheidtdav
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-23T13:56:28Z
user: login: scheidtdav
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-23T14:34:14Z
user: login: timber-they
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-25T13:47:46Z
user: login: timber-they
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-25T13:48:44Z
user: login: timber-they
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-04-25T13:50:42Z

Review comments:
diff_hunk: @@ -0,0 +1 @@
+v22.14.0
path: .nvmrc
user: login: timber-they
body: If we have this file in `git`, we at least also need to adjust [the README](https://github.com/mint-o-badges/oeb-test/blob/main/README.md?plain=1#L21)
created_at: 2025-04-15T10:47:40Z
author_association: CONTRIBUTOR
diff_hunk: @@ -40,35 +40,15 @@ export async function navigateToBadgeCreation(driver) {
     // wait here as well
     const expectedTitle = 'Issuers - Open Educational Badges';
     driver.wait(until.titleIs(expectedTitle), defaultWait);
-    // The rendering process of the issuers page is quite weird,
-    // so we wait for that to finish.
-    // For that we take the first card (and hope that it's a valid
-    // issuer) and wait until it has all three buttons
-    const card = await driver.wait(until.elementLocated(
-        ExtendedBy.containingText(
-            By.css('div.tw-border-purple.tw-grow'),
-            By.tagName('span'),
-            // Search for "Lernpfad erstellen" because only
-            // verified issuers have this button
-            'Lernpfad erstellen')),
-        defaultWait,
-        "Couldn't find card");
-    const condition = new Condition("for issuers to have fully loaded",
-        async driver => {
-            const children = await card.findElements(By.tagName('oeb-button'));
-            return children.length === 3;
-        });
-    await driver.wait(condition, defaultWait,
-        "Issuer loading didn't complete");
 
     await driver.wait(until.elementLocated(
         By.css("[id^='create-new-badge-btn']:not(.disabled)")),
-        defaultWait);
-    const createBadgeButton = await card.findElement(
+        extendedWait);
path: test/badge.js
user: login: timber-they
body: Why is an extended wait necessary here? If the button isn't present immediately, some process of explicitly waiting for it would be nicer imo.
created_at: 2025-04-15T10:49:38Z
author_association: CONTRIBUTOR
diff_hunk: @@ -136,52 +116,65 @@ export async function navigateToReceivedBadge(driver) {
 /**
  * This assumes that the driver already navigated to the badge creation page
  */
-export async function createBadge(driver, badgeType = 'Teilnahme') {
-    const categoryDropdownButton = await driver.findElement(By.css(
-        'button[role="combobox"]'));
-    await categoryDropdownButton.click();
+export async function createBadge(driver, badgeType = 'participation') {
+    // Initial step: Badge type selection
+    await driver.wait(until.elementLocated(By.css(`[href*='${badgeType}']`)), defaultWait);
 
-    // Category selection 
-    await driver.wait(until.elementLocated(
-        ExtendedBy.tagWithText('hlm-option', badgeType)), defaultWait);
-    const participationOption = await driver.findElement(
-        ExtendedBy.tagWithText('hlm-option', badgeType));
-    await participationOption.click();
+    const selectedBadgeType = await driver.findElement(
path: test/badge.js
user: login: timber-they
body: Note that you can also assign this directly, since `wait` seems to return the object as well.
created_at: 2025-04-15T10:52:40Z
author_association: CONTRIBUTOR
diff_hunk: @@ -136,52 +116,65 @@ export async function navigateToReceivedBadge(driver) {
 /**
  * This assumes that the driver already navigated to the badge creation page
  */
-export async function createBadge(driver, badgeType = 'Teilnahme') {
-    const categoryDropdownButton = await driver.findElement(By.css(
-        'button[role="combobox"]'));
-    await categoryDropdownButton.click();
+export async function createBadge(driver, badgeType = 'participation') {
+    // Initial step: Badge type selection
+    await driver.wait(until.elementLocated(By.css(`[href*='${badgeType}']`)), defaultWait);
 
-    // Category selection 
-    await driver.wait(until.elementLocated(
-        ExtendedBy.tagWithText('hlm-option', badgeType)), defaultWait);
-    const participationOption = await driver.findElement(
-        ExtendedBy.tagWithText('hlm-option', badgeType));
-    await participationOption.click();
+    const selectedBadgeType = await driver.findElement(
+        By.css(`[href*='${badgeType}']`)
+    );
+    await selectedBadgeType.click();
 
-    // Description field
-    const shortDescriptionField = await driver.findElement(By.css(
-        'textarea'));
-    await shortDescriptionField.sendKeys(testBadgeDescription);
+    // Click step 2, as sometimes it goes to 3rd step directly after step 1
path: test/badge.js
user: login: timber-they
body: This seems to be an error in the application? Is there a ticket for this already?
created_at: 2025-04-15T10:53:56Z
author_association: CONTRIBUTOR
diff_hunk: @@ -286,24 +278,19 @@ export function clearDownloadDirectory() {
 }
 
 export async function waitForDownload(driver, regex, timeout = 5000) {
+    const crdownloadRegExp = new RegExp(regex.source + "\.crdownload", regex.flags);
     const condition = new Condition("for download",
         driver => {
-            const files = fs.readdirSync(downloadDirectory);
-            if (files.length === 0)
-                return false;
-
-            let count = 0;
-            for (const file of files) {
-                if (regex.test(file)) {
-                    count++;
-                }
-            }
-
-            if (count === 0)
-                return false;
-
-            assert(count, 1, "Expected one downloaded file");
-            return true;
+            // Block until all downloads are finished (indicated by .crdownload files)
+            while(fs.readdirSync(downloadDirectory).some(f => 
+                crdownloadRegExp.test(f)).length > 0)
+                continue;
path: test/badge.js
user: login: timber-they
body: Is this busy wait really necessary?
created_at: 2025-04-15T10:56:44Z
author_association: CONTRIBUTOR
diff_hunk: @@ -286,24 +278,19 @@ export function clearDownloadDirectory() {
 }
 
 export async function waitForDownload(driver, regex, timeout = 5000) {
path: test/badge.js
user: login: timber-they
body: I'm a bit unhappy with the change to this function, because it does something completely different now. Before it was meant to purely wait for the download of one specific file, recognizable by the given `regex`. Now it does something way more specific. I wonder if
1. the changes are really necessary to achieve the desired behavior
2. this should then be in a different function
created_at: 2025-04-15T10:58:46Z
author_association: CONTRIBUTOR
diff_hunk: @@ -320,14 +307,14 @@ export async function revokeBadge(driver) {
         ExtendedBy.submitButtonWithText('zurücknehmen'));
     await revokeButton.click();
 
-    const confirmDialog = await driver.findElement(By.tagName('confirm-dialog'));
+    const confirmDialog = await driver.findElement(By.css('confirm-dialog'));
path: test/badge.js
user: login: timber-they
body: Why? There is no `css` necessary here, since it really is just the tag name
created_at: 2025-04-15T10:59:00Z
author_association: CONTRIBUTOR
diff_hunk: @@ -451,7 +438,8 @@ export async function validateBadge(driver, badgeType = 'Teilnahme') {
 
     if(badgeType == 'Kompetenz'){
         const BadgeCompetencies = await driver.findElements(By.css('competency-accordion'))
-        assert.equal(BadgeCompetencies.length, 3);
+        // sometimes due to a race condition the ai generated competency isn't properly added
+        assert.equal(BadgeCompetencies.length >= 1, true); 
path: test/badge.js
user: login: timber-they
body: Shouldn't then rather the race condition be fixed? This seems rather hacky
created_at: 2025-04-15T10:59:56Z
author_association: CONTRIBUTOR
diff_hunk: @@ -34,6 +34,7 @@ describe('Badge Test', function() {
         clearDownloadDirectory();
         // Set download directory path 
         let options = new chrome.Options();
+        options.addArguments("--lang=de-DE");
path: test/badge.spec.js
user: login: timber-they
body: Is this still necessary? It seems you changed the German names to English ones either way
created_at: 2025-04-15T11:00:47Z
author_association: CONTRIBUTOR
diff_hunk: @@ -61,7 +61,7 @@ describe('QR test', function() {
     it('should read the QR code', async function() {
         const qrCodeUrl = await readQrCode(new RegExp(`^${url}.*`));
         await driver.get(qrCodeUrl);
-        await requestBadgeViaQr(driver);
+        await requestBadgeViaQr(driver);        
path: test/qr.spec.js
user: login: timber-they
body: This seems like an accident
created_at: 2025-04-15T11:02:02Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1,9 +1,20 @@
 import { By, until } from "selenium-webdriver";
 import { defaultWait } from "../config.js";
 import path from "path";
+import { ExtendedBy } from "./selection.js";
 
-export async function uploadImage(driver, element_id, imagePath) {
-    const imageField = await driver.findElement(By.id(element_id));
+/**
+ * @param {any} driver 
+ * @param {string} element_id 
+ * @param {number} nthElement 
+ * @param {string} imagePath 
+ */
path: util/image-upload.js
user: login: timber-they
body: What is the purpose of this documentation?
created_at: 2025-04-15T11:02:58Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1,9 +1,20 @@
 import { By, until } from "selenium-webdriver";
 import { defaultWait } from "../config.js";
 import path from "path";
+import { ExtendedBy } from "./selection.js";
 
-export async function uploadImage(driver, element_id, imagePath) {
-    const imageField = await driver.findElement(By.id(element_id));
+/**
+ * @param {any} driver 
+ * @param {string} element_id 
+ * @param {number} nthElement 
+ * @param {string} imagePath 
+ */
+export async function uploadImage(driver, element_id, nthElement, imagePath) {
+    let imageField;
+    if(nthElement === 0)
+        imageField = await driver.findElement(By.xpath(`(//*[starts-with(@id, '${element_id}')])`));
path: util/image-upload.js
user: login: timber-they
body: I think we should try to stick to finding elements via `css` (or the tag name ^^)
created_at: 2025-04-15T11:04:18Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1,9 +1,20 @@
 import { By, until } from "selenium-webdriver";
 import { defaultWait } from "../config.js";
 import path from "path";
+import { ExtendedBy } from "./selection.js";
 
-export async function uploadImage(driver, element_id, imagePath) {
-    const imageField = await driver.findElement(By.id(element_id));
+/**
+ * @param {any} driver 
+ * @param {string} element_id 
+ * @param {number} nthElement 
+ * @param {string} imagePath 
+ */
+export async function uploadImage(driver, element_id, nthElement, imagePath) {
+    let imageField;
+    if(nthElement === 0)
+        imageField = await driver.findElement(By.xpath(`(//*[starts-with(@id, '${element_id}')])`));
+    else
+        imageField = await driver.findElement(By.xpath(`(//*[starts-with(@id, '${element_id}')])[${nthElement}]`));
path: util/image-upload.js
user: login: timber-they
body: How can there be multiple fields with the same ID? Generally finding elements with an index is rather unstable and I think we should avoid it wherever possible. I admit though that I also used it sometimes
created_at: 2025-04-15T11:05:19Z
author_association: CONTRIBUTOR
diff_hunk: @@ -16,18 +27,12 @@ export async function uploadImage(driver, element_id, imagePath) {
 
 export async function selectNounProjectImage(driver, searchText) {
     // Open noun-project dialog
-    // Try 5 times to avoid stale reference errors
-    for (let i = 0; i < 5; i++) {
-        try {
-            const nounProject_option = await driver.wait(
-                until.elementLocated(By.id("nounProject_span")),
-                defaultWait
-            );
-            await nounProject_option.click();
-            break;
-        } catch(e) {
-        }
-    }
+    const nounProject_option = await driver.wait(
path: util/image-upload.js
user: login: timber-they
body: Are you sure the stale reference errors don't appear anymore?
created_at: 2025-04-15T11:05:57Z
author_association: CONTRIBUTOR
diff_hunk: @@ -286,24 +278,19 @@ export function clearDownloadDirectory() {
 }
 
 export async function waitForDownload(driver, regex, timeout = 5000) {
+    const crdownloadRegExp = new RegExp(regex.source + "\.crdownload", regex.flags);
     const condition = new Condition("for download",
         driver => {
-            const files = fs.readdirSync(downloadDirectory);
-            if (files.length === 0)
-                return false;
-
-            let count = 0;
-            for (const file of files) {
-                if (regex.test(file)) {
-                    count++;
-                }
-            }
-
-            if (count === 0)
-                return false;
-
-            assert(count, 1, "Expected one downloaded file");
-            return true;
+            // Block until all downloads are finished (indicated by .crdownload files)
+            while(fs.readdirSync(downloadDirectory).some(f => 
+                crdownloadRegExp.test(f)).length > 0)
+                continue;
path: test/badge.js
user: login: scheidtdav
body: As far as I was able to figure out, unfortunately yes. At least with reasonable effort.
If we are just checking for existence of the file (like before) we will always return immediately or never, depending on which case hits first:
1. The download is started before accessing the file system -> a 0 byte pdf is written + a .crdownload file
2. The download is started after accessing the file system -> no .pdf file is found leading to a timeout

This is rarely executed more than three times during debugging and it is not production code, do you think its fine for that purpose? I didn't want to start fiddling with filewatchers, but they would probably be an option.
created_at: 2025-04-15T13:55:04Z
author_association: MEMBER
diff_hunk: @@ -320,14 +307,14 @@ export async function revokeBadge(driver) {
         ExtendedBy.submitButtonWithText('zurücknehmen'));
     await revokeButton.click();
 
-    const confirmDialog = await driver.findElement(By.tagName('confirm-dialog'));
+    const confirmDialog = await driver.findElement(By.css('confirm-dialog'));
path: test/badge.js
user: login: scheidtdav
body: `By.tagName` is deprecated and its [deprecation note](https://github.com/DefinitelyTyped/DefinitelyTyped/blob/14c8515538cbfce1f17e5bfff66571a7ae112e06/types/selenium-webdriver/lib/by.d.ts#L91) states to use `css` instead.
That does make it easier to move to more specific selectors if necessary in the future and the tag name is also a perfectly valid selector.

Haven't changed the other occurrences yet, but am happy to do so if fine for you.
created_at: 2025-04-15T14:02:40Z
author_association: MEMBER
diff_hunk: @@ -451,7 +438,8 @@ export async function validateBadge(driver, badgeType = 'Teilnahme') {
 
     if(badgeType == 'Kompetenz'){
         const BadgeCompetencies = await driver.findElements(By.css('competency-accordion'))
-        assert.equal(BadgeCompetencies.length, 3);
+        // sometimes due to a race condition the ai generated competency isn't properly added
+        assert.equal(BadgeCompetencies.length >= 1, true); 
path: test/badge.js
user: login: scheidtdav
body: Absolutely! Will do, just wanted to move forward and forgot to come back to this.
Thanks for the reminder :)
created_at: 2025-04-15T14:04:36Z
author_association: MEMBER
diff_hunk: @@ -34,6 +34,7 @@ describe('Badge Test', function() {
         clearDownloadDirectory();
         // Set download directory path 
         let options = new chrome.Options();
+        options.addArguments("--lang=de-DE");
path: test/badge.spec.js
user: login: scheidtdav
body: Not yet, no. Translations are coming though.
Being explicit here prevents the tests from immediately breaking when that happens.

As I am typing this, I am realizing this might be what we want though.
What do you think? Should the tests break when we add translations s.t. we are reminded to add more tests for it?
created_at: 2025-04-15T14:08:31Z
author_association: MEMBER
diff_hunk: @@ -1,9 +1,20 @@
 import { By, until } from "selenium-webdriver";
 import { defaultWait } from "../config.js";
 import path from "path";
+import { ExtendedBy } from "./selection.js";
 
-export async function uploadImage(driver, element_id, imagePath) {
-    const imageField = await driver.findElement(By.id(element_id));
+/**
+ * @param {any} driver 
+ * @param {string} element_id 
+ * @param {number} nthElement 
+ * @param {string} imagePath 
+ */
path: util/image-upload.js
user: login: scheidtdav
body: Whoops I forgot to add the actual text.
I mostly added it for type checking without the need for a compiler though
created_at: 2025-04-15T14:11:57Z
author_association: MEMBER
diff_hunk: @@ -1,9 +1,20 @@
 import { By, until } from "selenium-webdriver";
 import { defaultWait } from "../config.js";
 import path from "path";
+import { ExtendedBy } from "./selection.js";
 
-export async function uploadImage(driver, element_id, imagePath) {
-    const imageField = await driver.findElement(By.id(element_id));
+/**
+ * @param {any} driver 
+ * @param {string} element_id 
+ * @param {number} nthElement 
+ * @param {string} imagePath 
+ */
+export async function uploadImage(driver, element_id, nthElement, imagePath) {
+    let imageField;
+    if(nthElement === 0)
+        imageField = await driver.findElement(By.xpath(`(//*[starts-with(@id, '${element_id}')])`));
+    else
+        imageField = await driver.findElement(By.xpath(`(//*[starts-with(@id, '${element_id}')])[${nthElement}]`));
path: util/image-upload.js
user: login: scheidtdav
body: I would love to know as well :sweat_smile: I mostly tried getting the tests to pass without the need for changes in the ui code.
I found some components that simply used an id on elements but that were also repeated in a list, which of course fundamentally breaks the idea of an id.
This does need refactoring, but I'd suggest to start that on the ui side first. What do you think?
created_at: 2025-04-15T14:16:32Z
author_association: MEMBER
diff_hunk: @@ -1,9 +1,20 @@
 import { By, until } from "selenium-webdriver";
 import { defaultWait } from "../config.js";
 import path from "path";
+import { ExtendedBy } from "./selection.js";
 
-export async function uploadImage(driver, element_id, imagePath) {
-    const imageField = await driver.findElement(By.id(element_id));
+/**
+ * @param {any} driver 
+ * @param {string} element_id 
+ * @param {number} nthElement 
+ * @param {string} imagePath 
+ */
+export async function uploadImage(driver, element_id, nthElement, imagePath) {
+    let imageField;
+    if(nthElement === 0)
+        imageField = await driver.findElement(By.xpath(`(//*[starts-with(@id, '${element_id}')])`));
path: util/image-upload.js
user: login: scheidtdav
body: ergh yes. I was doing something else here before, will change this!
created_at: 2025-04-15T14:17:07Z
author_association: MEMBER
diff_hunk: @@ -16,18 +27,12 @@ export async function uploadImage(driver, element_id, imagePath) {
 
 export async function selectNounProjectImage(driver, searchText) {
     // Open noun-project dialog
-    // Try 5 times to avoid stale reference errors
-    for (let i = 0; i < 5; i++) {
-        try {
-            const nounProject_option = await driver.wait(
-                until.elementLocated(By.id("nounProject_span")),
-                defaultWait
-            );
-            await nounProject_option.click();
-            break;
-        } catch(e) {
-        }
-    }
+    const nounProject_option = await driver.wait(
path: util/image-upload.js
user: login: scheidtdav
body: I probably ran the tests 30 times and didn't have this once.
Of course, absence of evidence is not evidence of absence... Is there some sort of log of this issue?
Just to know what it looks like?
created_at: 2025-04-15T14:19:40Z
author_association: MEMBER
diff_hunk: @@ -136,52 +116,65 @@ export async function navigateToReceivedBadge(driver) {
 /**
  * This assumes that the driver already navigated to the badge creation page
  */
-export async function createBadge(driver, badgeType = 'Teilnahme') {
-    const categoryDropdownButton = await driver.findElement(By.css(
-        'button[role="combobox"]'));
-    await categoryDropdownButton.click();
+export async function createBadge(driver, badgeType = 'participation') {
+    // Initial step: Badge type selection
+    await driver.wait(until.elementLocated(By.css(`[href*='${badgeType}']`)), defaultWait);
 
-    // Category selection 
-    await driver.wait(until.elementLocated(
-        ExtendedBy.tagWithText('hlm-option', badgeType)), defaultWait);
-    const participationOption = await driver.findElement(
-        ExtendedBy.tagWithText('hlm-option', badgeType));
-    await participationOption.click();
+    const selectedBadgeType = await driver.findElement(
+        By.css(`[href*='${badgeType}']`)
+    );
+    await selectedBadgeType.click();
 
-    // Description field
-    const shortDescriptionField = await driver.findElement(By.css(
-        'textarea'));
-    await shortDescriptionField.sendKeys(testBadgeDescription);
+    // Click step 2, as sometimes it goes to 3rd step directly after step 1
path: test/badge.js
user: login: scheidtdav
body: I don't think so. Not sure if it still happens..
We could take this out and see if the tests fail here?
created_at: 2025-04-16T08:40:02Z
author_association: MEMBER
diff_hunk: @@ -286,24 +278,19 @@ export function clearDownloadDirectory() {
 }
 
 export async function waitForDownload(driver, regex, timeout = 5000) {
path: test/badge.js
user: login: scheidtdav
body: Doesn't it now do what it was supposed to do in the first place?
I understood this as "wait for the download to finish", which it didn't do.

When downloading a file (say `foo.pdf`), chrome will immediately create `foo.pdf` and `foo.pdf.crdownload`.
The original implementation would simply return true straight away, since `foo.pdf` is already in the file system.

I totally agree that the implementation now is a lot more specific. This would e.g. not work if we switch the test to run in firefox.
Unfortunately we don't know the final size of the pdf for comparison (or do we?).
Being specific here seemed like the best balance between effort/outcome.
created_at: 2025-04-16T08:44:47Z
author_association: MEMBER
diff_hunk: @@ -1,9 +1,20 @@
 import { By, until } from "selenium-webdriver";
 import { defaultWait } from "../config.js";
 import path from "path";
+import { ExtendedBy } from "./selection.js";
 
-export async function uploadImage(driver, element_id, imagePath) {
-    const imageField = await driver.findElement(By.id(element_id));
+/**
+ * @param {any} driver 
+ * @param {string} element_id 
+ * @param {number} nthElement 
+ * @param {string} imagePath 
+ */
+export async function uploadImage(driver, element_id, nthElement, imagePath) {
+    let imageField;
+    if(nthElement === 0)
+        imageField = await driver.findElement(By.xpath(`(//*[starts-with(@id, '${element_id}')])`));
+    else
+        imageField = await driver.findElement(By.xpath(`(//*[starts-with(@id, '${element_id}')])[${nthElement}]`));
path: util/image-upload.js
user: login: scheidtdav
body: I just revisited this and realized thats not what was happening here. Even though there are some components that share the same id.

Consider the image upload feature for a badge.
There are two options to choose from, both input ids starting with "image_field".
This method now easily allows to choose the second one by passing in n = 1.

The problem arised from one of the badges where the image fields weren't numbered in ascending order from 0 but from 1.
This makes it easier and more stable if these indices ever change again, since we are still selecting the second occurrence in the DOM rather than the exact id.
created_at: 2025-04-16T08:58:35Z
author_association: MEMBER
diff_hunk: @@ -286,24 +278,19 @@ export function clearDownloadDirectory() {
 }
 
 export async function waitForDownload(driver, regex, timeout = 5000) {
+    const crdownloadRegExp = new RegExp(regex.source + "\.crdownload", regex.flags);
     const condition = new Condition("for download",
         driver => {
-            const files = fs.readdirSync(downloadDirectory);
-            if (files.length === 0)
-                return false;
-
-            let count = 0;
-            for (const file of files) {
-                if (regex.test(file)) {
-                    count++;
-                }
-            }
-
-            if (count === 0)
-                return false;
-
-            assert(count, 1, "Expected one downloaded file");
-            return true;
+            // Block until all downloads are finished (indicated by .crdownload files)
+            while(fs.readdirSync(downloadDirectory).some(f => 
+                crdownloadRegExp.test(f)).length > 0)
+                continue;
path: test/badge.js
user: login: timber-they
body: I'm still not convinced that this is necessary, since the `Condition` object is *supposed* to return immediately. The waiting is done by the `driver.wait` below
created_at: 2025-04-17T12:10:13Z
author_association: CONTRIBUTOR
diff_hunk: @@ -286,24 +278,19 @@ export function clearDownloadDirectory() {
 }
 
 export async function waitForDownload(driver, regex, timeout = 5000) {
path: test/badge.js
user: login: timber-they
body: I don't think that's true. Chrome immediately creates the file `foo.pdf.crdownload`, but not `foo.pdf`; it is only renamed to `foo.pdf` upon completion. So this did actually do what it was supposed to do, didn't it?
created_at: 2025-04-17T12:15:53Z
author_association: CONTRIBUTOR
diff_hunk: @@ -34,6 +34,7 @@ describe('Badge Test', function() {
         clearDownloadDirectory();
         // Set download directory path 
         let options = new chrome.Options();
+        options.addArguments("--lang=de-DE");
path: test/badge.spec.js
user: login: timber-they
body: I think both is fine, but it should be consistent at least. Right now the argument is only added to the badge tests
created_at: 2025-04-17T12:17:20Z
author_association: CONTRIBUTOR
diff_hunk: @@ -16,18 +27,12 @@ export async function uploadImage(driver, element_id, imagePath) {
 
 export async function selectNounProjectImage(driver, searchText) {
     // Open noun-project dialog
-    // Try 5 times to avoid stale reference errors
-    for (let i = 0; i < 5; i++) {
-        try {
-            const nounProject_option = await driver.wait(
-                until.elementLocated(By.id("nounProject_span")),
-                defaultWait
-            );
-            await nounProject_option.click();
-            break;
-        } catch(e) {
-        }
-    }
+    const nounProject_option = await driver.wait(
path: util/image-upload.js
user: login: timber-they
body: We can try it out, but I think sometimes the error only occurs on specific machines. This is a log (from #4):
```js
     StaleElementReferenceError: stale element reference: stale element not found in the current frame
  (Session info: chrome=131.0.6778.204)
      at Object.throwDecodedError (node_modules/selenium-webdriver/lib/error.js:521:15)
      at parseHttpResponse (node_modules/selenium-webdriver/lib/http.js:514:13)
      at Executor.execute (node_modules/selenium-webdriver/lib/http.js:446:28)
      at process.processTicksAndRejections (node:internal/process/task_queues:95:5)
      at async Driver.execute (node_modules/selenium-webdriver/lib/webdriver.js:744:17)
      at async selectNounProjectImage (file:///home/timber/Documents/oeb-test/util/image-upload.js:23:3)
      at async createBadge (file:///home/timber/Documents/oeb-test/test/badge.js:166:5)
      at async Context.<anonymous> (file:///home/timber/Documents/oeb-test/test/badge.spec.js:90:9)
```
created_at: 2025-04-17T12:20:00Z
author_association: CONTRIBUTOR
diff_hunk: @@ -34,6 +34,7 @@ describe('Badge Test', function() {
         clearDownloadDirectory();
         // Set download directory path 
         let options = new chrome.Options();
+        options.addArguments("--lang=de-DE");
path: test/badge.spec.js
user: login: scheidtdav
body: Good point. I'll take it out until we introduce proper translations. We can then do it properly for all tests once.
created_at: 2025-04-22T07:34:22Z
author_association: MEMBER
diff_hunk: @@ -286,24 +278,19 @@ export function clearDownloadDirectory() {
 }
 
 export async function waitForDownload(driver, regex, timeout = 5000) {
path: test/badge.js
user: login: scheidtdav
body: I just double-checked and it turns out you are absolutely right!
Not sure how I mixed it up, but sorry for the misconception.
created_at: 2025-04-22T07:38:44Z
author_association: MEMBER
diff_hunk: @@ -286,24 +278,19 @@ export function clearDownloadDirectory() {
 }
 
 export async function waitForDownload(driver, regex, timeout = 5000) {
+    const crdownloadRegExp = new RegExp(regex.source + "\.crdownload", regex.flags);
     const condition = new Condition("for download",
         driver => {
-            const files = fs.readdirSync(downloadDirectory);
-            if (files.length === 0)
-                return false;
-
-            let count = 0;
-            for (const file of files) {
-                if (regex.test(file)) {
-                    count++;
-                }
-            }
-
-            if (count === 0)
-                return false;
-
-            assert(count, 1, "Expected one downloaded file");
-            return true;
+            // Block until all downloads are finished (indicated by .crdownload files)
+            while(fs.readdirSync(downloadDirectory).some(f => 
+                crdownloadRegExp.test(f)).length > 0)
+                continue;
path: test/badge.js
user: login: scheidtdav
body: :facepalm: of course! `driver.wait` does seem to poll. I 'll revert my change and see what my original problem with the download was about again.
Maybe it turns out to be unrelated to this.
(I must admit, I did had to get familiar with selenium again to begin with...)
created_at: 2025-04-22T07:42:04Z
author_association: MEMBER
diff_hunk: @@ -113,7 +123,65 @@ describe('Badge Test', function() {
 
     it('delete the competency badge', async function() {
         await deleteBadgeOverApi();
+    });
+
+    // micro degree
+    it('should create a micro degree', async () => {
+        // We need at least three badges to put as part of the degree
+        await createBadges(driver, BADGES_FOR_MICRO_DEGREE);
+        await navigateToBadgeCreation(driver);
+        await createMicroDegree(driver, BADGES_FOR_MICRO_DEGREE);
+    })
+    .timeout(this.timeout() + BADGES_FOR_MICRO_DEGREE * 10_000); // Allow larger timeout since a number of badges have to be created
path: test/badge.spec.js
user: login: timber-they
body: I don't like magic numbers. Like, where does this 10000 come from?
Also, adding features during the review process can lead to a perpetual review cycle I think.
created_at: 2025-04-23T12:16:15Z
author_association: CONTRIBUTOR
diff_hunk: @@ -113,7 +123,65 @@ describe('Badge Test', function() {
 
     it('delete the competency badge', async function() {
         await deleteBadgeOverApi();
+    });
+
+    // micro degree
+    it('should create a micro degree', async () => {
+        // We need at least three badges to put as part of the degree
+        await createBadges(driver, BADGES_FOR_MICRO_DEGREE);
+        await navigateToBadgeCreation(driver);
+        await createMicroDegree(driver, BADGES_FOR_MICRO_DEGREE);
+    })
+    .timeout(this.timeout() + BADGES_FOR_MICRO_DEGREE * 10_000); // Allow larger timeout since a number of badges have to be created
+
+    it('should award the micro degree', async function() {
+        // users get micro degrees automatically by receiving all badges
+        // contained in the degree itself
+        for(let i = 0; i < BADGES_FOR_MICRO_DEGREE; i++) {
+            await navigateToBadgeAwarding(driver, i);
+            await awardBadge(driver);
+        }
+    })
+    .timeout(this.timeout() + BADGES_FOR_MICRO_DEGREE * 10_000); // Allow larger timeout since awarding badges takes a while
+
+    it('should receive the micro degree', async function() {
path: test/badge.spec.js
user: login: timber-they
body: Micro degrees seem like a big test case. Maybe they should have their own test files (similar to `badge.spec.js` and `badge.js`)?
created_at: 2025-04-23T12:17:26Z
author_association: CONTRIBUTOR
diff_hunk: @@ -67,22 +78,56 @@ export async function navigateToBadgeDetails(driver) {
         By.css('span.tw-text-oebblack')), defaultWait);
     const spans = Array.from(await driver.findElements(
         By.css('span.tw-text-oebblack')));
+    let toSkip = skip;
     for (const span of spans) {
         const text = await span.getText();
         if (text === testBadgeTitle) {
-            span.click();
-            break;
+            if(toSkip === 0) {
+                span.click();
+                break;
+            }
+            toSkip--;
         }
     }
 
     await driver.wait(until.titleIs(`Badge Class - ${testBadgeTitle} - Open Educational Badges`), defaultWait);
 }
 
+export async function navigateToMicroDegreeDetails(driver) {
+    // This ensures that the same issuer is used as for the created badge
+    await navigateToBadgeCreation(driver);
+
+    await driver.wait(until.elementLocated(By.css('span.breadcrumbs-x-text')), defaultWait);
path: test/badge.js
user: login: timber-they
body: You can assign the breadcrumbs directly (`const breadcrumbs = await driver.wait(...)`)
created_at: 2025-04-23T12:21:02Z
author_association: CONTRIBUTOR
diff_hunk: @@ -67,22 +78,56 @@ export async function navigateToBadgeDetails(driver) {
         By.css('span.tw-text-oebblack')), defaultWait);
     const spans = Array.from(await driver.findElements(
         By.css('span.tw-text-oebblack')));
+    let toSkip = skip;
     for (const span of spans) {
         const text = await span.getText();
         if (text === testBadgeTitle) {
-            span.click();
-            break;
+            if(toSkip === 0) {
+                span.click();
+                break;
+            }
+            toSkip--;
         }
     }
 
     await driver.wait(until.titleIs(`Badge Class - ${testBadgeTitle} - Open Educational Badges`), defaultWait);
 }
 
+export async function navigateToMicroDegreeDetails(driver) {
+    // This ensures that the same issuer is used as for the created badge
+    await navigateToBadgeCreation(driver);
+
+    await driver.wait(until.elementLocated(By.css('span.breadcrumbs-x-text')), defaultWait);
+    const breadcrumbs = await driver.findElements(By.css(
+        'span.breadcrumbs-x-text'));
+    const issuerBreadcrumb = breadcrumbs[1];
+    issuerBreadcrumb.click();
+
+    await driver.wait(until.titleMatches(/Issuer - .* - Open Educational Badges/), defaultWait);
+
+    await driver.wait(until.elementLocated(
+        By.css('hlm-tabs-list')), defaultWait);
+    const tabs = Array.from(await driver.findElements(
path: test/badge.js
user: login: timber-they
body: Do you really need to convert it to an array? And if you would change the `wait` to also wait for the `button`, you could assign the tabs directly
created_at: 2025-04-23T12:22:33Z
author_association: CONTRIBUTOR
diff_hunk: @@ -225,6 +278,17 @@ export async function receiveBadge(driver) {
     assert.equal(receivedBadges.length, 1, "Expected to have received one badge with the specified title");
 }
 
+/**
+ * This assumes that the driver already navigated to the backpack page
+ */
+export async function receiveMicroDegreeBadge(driver) {
+    // This checks if the micro degree badge appears in the backpack
+    await driver.wait(until.elementLocated(By.linkText(microDegreeTitle)), defaultWait);
+    const receivedBadges = await driver.findElements(By.linkText(
path: test/badge.js
user: login: timber-they
body: You can assign `receivedBadges` directly
created_at: 2025-04-23T12:24:24Z
author_association: CONTRIBUTOR
diff_hunk: @@ -327,10 +410,43 @@ export async function confirmRevokedBadge(driver) {
     assert.equal(receivedBadges.length, 0, "Expected to have received no badge with the specified title");
 }
 
-export async function deleteBadgeOverApi() {
+/**
+ * Revokes a micro degree from the micro degrees details page.
+ * This assumes that the driver already navigated to the micro degree detail page.
+ * @param {import('selenium-webdriver').ThenableWebDriver} driver 
+ */
+export async function revokeMicroDegree(driver) {
+    await driver.wait(until.elementLocated(
+        ExtendedBy.submitButtonWithText('zurücknehmen')),
+        defaultWait);
+    const revokeButton = await driver.findElement(
path: test/badge.js
user: login: timber-they
body: You can assign `revokeButton` directly
created_at: 2025-04-23T12:25:39Z
author_association: CONTRIBUTOR
diff_hunk: @@ -458,3 +573,105 @@ export async function verifyBadgeOverApi() {
     // Study-load is in minutes while test-duration is in hours
     assert.equal(studyLoad/60, testDuration);
 }
+
+/**
+ * Creates {@link n} number of  badges
+ * @param {import('selenium-webdriver').ThenableWebDriver} driver 
+ * @param {number} n Number of badges to create
+ */
+export async function createBadges(driver, n) {
+    for(let i = 0; i < n; i++) {
+        await navigateToBadgeCreation(driver);
+        await createBadge(driver);
+    }
+}
+
+/**
+ * Creates a micro degree that includes the first {@link n} badges
+ * available to the logged in user
+ * @param {import('selenium-webdriver').ThenableWebDriver} driver 
+ * @param {number} n Number of badges to include
+ */
+export async function createMicroDegree(driver, n) {
+    // Initial step: Badge type selection
+    await driver.wait(until.elementLocated(By.css('[href*="learningpaths/create"]')), defaultWait);
+
+    const selectedBadgeType = await driver.findElement(
path: test/badge.js
user: login: timber-they
body: You can assign `selectedBadgeType` directly
created_at: 2025-04-23T12:27:36Z
author_association: CONTRIBUTOR
diff_hunk: @@ -458,3 +573,105 @@ export async function verifyBadgeOverApi() {
     // Study-load is in minutes while test-duration is in hours
     assert.equal(studyLoad/60, testDuration);
 }
+
+/**
+ * Creates {@link n} number of  badges
+ * @param {import('selenium-webdriver').ThenableWebDriver} driver 
+ * @param {number} n Number of badges to create
+ */
+export async function createBadges(driver, n) {
+    for(let i = 0; i < n; i++) {
+        await navigateToBadgeCreation(driver);
+        await createBadge(driver);
+    }
+}
+
+/**
+ * Creates a micro degree that includes the first {@link n} badges
+ * available to the logged in user
+ * @param {import('selenium-webdriver').ThenableWebDriver} driver 
+ * @param {number} n Number of badges to include
+ */
+export async function createMicroDegree(driver, n) {
+    // Initial step: Badge type selection
+    await driver.wait(until.elementLocated(By.css('[href*="learningpaths/create"]')), defaultWait);
+
+    const selectedBadgeType = await driver.findElement(
+        By.css('[href*="learningpaths/create"]')
+    );
+    await selectedBadgeType.click();
+
+    // Next step: Badge details
+    // Title field
+    const titleField = await driver.findElement(By.css(
+        'input[type="text"]'));
+    await titleField.sendKeys(microDegreeTitle);
+
+    // Description
+    const descriptionField = await driver.findElement(By.css(
+        'textarea'));
+    await descriptionField.sendKeys(microDegreeDescription);
+    
+    // Image field
+    // Testing switching between framed and unframed/owned images is essential as users might experience some issues while doing so
+    // 1. Upload own image (insterted into badge frame)
+    await uploadImage(driver, "image_field", 0, testImagePath);
+    // 2. Upload own image
+    await uploadImage(driver, "image_field", 1, testImagePath);
+    // 3. Select an image from nounproject
+    await selectNounProjectImage(driver, nounProjectSearchText);
+
+    // Click next button to move to the next step
+    const nextButton = await driver.findElement(
+        ExtendedBy.tagWithText('span', 'Weiter'));
+    await nextButton.click();
+
+    // Next step: Add badges to the micro degree
+    const selectableCards = await driver.findElements(
+        ExtendedBy.containingText(
+            By.css('bg-badgecard'),
+            By.css('a'),
+            testBadgeTitle
+        ));
+
+    for(let i = 0; i < n; i++)
+        await selectableCards[i].findElement(By.css('hlm-checkbox')).click();
+
+    // Next step: Order of the badges
+    await nextButton.click();
+    const badgeCards = await driver.findElements(By.css(
+        'bg-badgecard'
+    ));
+    const dragAndDropAction = new Actions(driver)
+        .dragAndDrop(badgeCards[0], badgeCards.at(-1));
+    await dragAndDropAction.perform();
+
+    // Next step: Tag and Create
+    await nextButton.click();
+    await addNewTag(driver, tagName);   
+    
+    const submitForm = await driver.findElement(By.css('form'));
+    await submitForm.submit();
+
+    // The regular expression is for urls of the micro degree like 
+    // '/issuer/issuers/{issuerID}/learningpaths/{learningpathID}'
+    // but it disallows the path where the micro degree is created:
+    // '/issuer/issuers/{issuerID}/learningpaths/create'
+    await driver.wait(until.urlMatches(/\/issuer\/issuers\/[^\/]+\/learningpaths\/(?!create$)[^\/]+$/), extendedWait);
+}
+
+/**
+ * Deletes the micro degree using api requests without UI
+ */
+export const deleteMicroDegreeOverApi =
+    () => deleteBadgeOverApi(microDegreeTitle);
+
+/**
+ * Deletes the given number of badges via the api, essentially
+ * calling {@link deleteBadgeOverApi} multiple times.
+ * @param {number} n The number of badges to delete
+ */
+export async function deleteBadgesOverApi(n) {
+    for(let i = 0; i < n; i++)
path: test/badge.js
user: login: timber-they
body: It's a bit weird that the created badges all have the same name. Maybe it would be nicer to give them a title like *`automated test title 1`* and then also have a function that deletes based on that?
created_at: 2025-04-23T12:32:55Z
author_association: CONTRIBUTOR
diff_hunk: @@ -4,17 +4,18 @@ import path from "path";
 import { ExtendedBy } from "./selection.js";
 
 /**
+ * Uploads the image at {@link imagePath} to the {@link n}th element whose id starts with {@link element_id}
  * @param {any} driver 
- * @param {string} element_id 
- * @param {number} nthElement 
- * @param {string} imagePath 
+ * @param {string} element_id Id of the element to upload the image to
+ * @param {number} nthElement Number of the element with the given id
+ * @param {string} imagePath Path to the image file to upload
  */
 export async function uploadImage(driver, element_id, nthElement, imagePath) {
     let imageField;
     if(nthElement === 0)
-        imageField = await driver.findElement(By.xpath(`(//*[starts-with(@id, '${element_id}')])`));
+        imageField = await driver.findElement(By.css(`[id^='${element_id}']`));
     else
-        imageField = await driver.findElement(By.xpath(`(//*[starts-with(@id, '${element_id}')])[${nthElement}]`));
+        imageField = (await driver.findElements(By.css(`[id^='${element_id}']`)))[nthElement - 1];
path: util/image-upload.js
user: login: timber-they
body: Does this mean that `nthElement` is a 1-based index? If so, why? And if so, this should go in the doc of the function
created_at: 2025-04-23T12:34:39Z
author_association: CONTRIBUTOR
diff_hunk: @@ -67,22 +78,56 @@ export async function navigateToBadgeDetails(driver) {
         By.css('span.tw-text-oebblack')), defaultWait);
     const spans = Array.from(await driver.findElements(
         By.css('span.tw-text-oebblack')));
+    let toSkip = skip;
     for (const span of spans) {
         const text = await span.getText();
         if (text === testBadgeTitle) {
-            span.click();
-            break;
+            if(toSkip === 0) {
+                span.click();
+                break;
+            }
+            toSkip--;
         }
     }
 
     await driver.wait(until.titleIs(`Badge Class - ${testBadgeTitle} - Open Educational Badges`), defaultWait);
 }
 
+export async function navigateToMicroDegreeDetails(driver) {
+    // This ensures that the same issuer is used as for the created badge
+    await navigateToBadgeCreation(driver);
+
+    await driver.wait(until.elementLocated(By.css('span.breadcrumbs-x-text')), defaultWait);
path: test/badge.js
user: login: scheidtdav
body: I am happy to change all of these, but wasn't sure if I really should.
Its the code that was there before and it works fine and since this already took quite some time, I didn't really saw a reason why this could not be done when its modified in the future anyways.

I take this as a "yeah lets do it now" then, right?
created_at: 2025-04-23T13:15:00Z
author_association: MEMBER
diff_hunk: @@ -67,22 +78,56 @@ export async function navigateToBadgeDetails(driver) {
         By.css('span.tw-text-oebblack')), defaultWait);
     const spans = Array.from(await driver.findElements(
         By.css('span.tw-text-oebblack')));
+    let toSkip = skip;
     for (const span of spans) {
         const text = await span.getText();
         if (text === testBadgeTitle) {
-            span.click();
-            break;
+            if(toSkip === 0) {
+                span.click();
+                break;
+            }
+            toSkip--;
         }
     }
 
     await driver.wait(until.titleIs(`Badge Class - ${testBadgeTitle} - Open Educational Badges`), defaultWait);
 }
 
+export async function navigateToMicroDegreeDetails(driver) {
+    // This ensures that the same issuer is used as for the created badge
+    await navigateToBadgeCreation(driver);
+
+    await driver.wait(until.elementLocated(By.css('span.breadcrumbs-x-text')), defaultWait);
+    const breadcrumbs = await driver.findElements(By.css(
+        'span.breadcrumbs-x-text'));
+    const issuerBreadcrumb = breadcrumbs[1];
+    issuerBreadcrumb.click();
+
+    await driver.wait(until.titleMatches(/Issuer - .* - Open Educational Badges/), defaultWait);
+
+    await driver.wait(until.elementLocated(
+        By.css('hlm-tabs-list')), defaultWait);
+    const tabs = Array.from(await driver.findElements(
path: test/badge.js
user: login: scheidtdav
body: Quite frankly, I copied that from `navigateToBadgeDetails` and only adjusted as necessary. Should have asked myself the same question.
created_at: 2025-04-23T13:17:51Z
author_association: MEMBER
diff_hunk: @@ -113,7 +123,65 @@ describe('Badge Test', function() {
 
     it('delete the competency badge', async function() {
         await deleteBadgeOverApi();
+    });
+
+    // micro degree
+    it('should create a micro degree', async () => {
+        // We need at least three badges to put as part of the degree
+        await createBadges(driver, BADGES_FOR_MICRO_DEGREE);
+        await navigateToBadgeCreation(driver);
+        await createMicroDegree(driver, BADGES_FOR_MICRO_DEGREE);
+    })
+    .timeout(this.timeout() + BADGES_FOR_MICRO_DEGREE * 10_000); // Allow larger timeout since a number of badges have to be created
+
+    it('should award the micro degree', async function() {
+        // users get micro degrees automatically by receiving all badges
+        // contained in the degree itself
+        for(let i = 0; i < BADGES_FOR_MICRO_DEGREE; i++) {
+            await navigateToBadgeAwarding(driver, i);
+            await awardBadge(driver);
+        }
+    })
+    .timeout(this.timeout() + BADGES_FOR_MICRO_DEGREE * 10_000); // Allow larger timeout since awarding badges takes a while
+
+    it('should receive the micro degree', async function() {
path: test/badge.spec.js
user: login: scheidtdav
body: It is certainly not small, yes. However its tightly coupled to badges a) through the UI and b) conceptually, since micro degrees are badges themselves.
I totally see a benefit of having the tests more structured, but may I suggest that we move this into a separate issue then?
We could do some refactoring prior to adding more test cases, that could split this up into the three categories with some common base implementation (like navigating to the backpack and whatnot).
I believe the benefits of finishing this and having tests that run successfully outweigh refactoring here.
created_at: 2025-04-23T13:46:26Z
author_association: MEMBER
diff_hunk: @@ -67,22 +78,56 @@ export async function navigateToBadgeDetails(driver) {
         By.css('span.tw-text-oebblack')), defaultWait);
     const spans = Array.from(await driver.findElements(
         By.css('span.tw-text-oebblack')));
+    let toSkip = skip;
     for (const span of spans) {
         const text = await span.getText();
         if (text === testBadgeTitle) {
-            span.click();
-            break;
+            if(toSkip === 0) {
+                span.click();
+                break;
+            }
+            toSkip--;
         }
     }
 
     await driver.wait(until.titleIs(`Badge Class - ${testBadgeTitle} - Open Educational Badges`), defaultWait);
 }
 
+export async function navigateToMicroDegreeDetails(driver) {
+    // This ensures that the same issuer is used as for the created badge
+    await navigateToBadgeCreation(driver);
+
+    await driver.wait(until.elementLocated(By.css('span.breadcrumbs-x-text')), defaultWait);
path: test/badge.js
user: login: scheidtdav
body: Went into the code to the the change just now.
@timber-they  Please correct me if I'm wrong, but `elementLocated` only returns the first result right?
This would not work in this particular case, since I need to click `breadcrumbs[1]`.

I will however change this for the other occurences
created_at: 2025-04-23T13:56:27Z
author_association: MEMBER
diff_hunk: @@ -458,3 +573,105 @@ export async function verifyBadgeOverApi() {
     // Study-load is in minutes while test-duration is in hours
     assert.equal(studyLoad/60, testDuration);
 }
+
+/**
+ * Creates {@link n} number of  badges
+ * @param {import('selenium-webdriver').ThenableWebDriver} driver 
+ * @param {number} n Number of badges to create
+ */
+export async function createBadges(driver, n) {
+    for(let i = 0; i < n; i++) {
+        await navigateToBadgeCreation(driver);
+        await createBadge(driver);
+    }
+}
+
+/**
+ * Creates a micro degree that includes the first {@link n} badges
+ * available to the logged in user
+ * @param {import('selenium-webdriver').ThenableWebDriver} driver 
+ * @param {number} n Number of badges to include
+ */
+export async function createMicroDegree(driver, n) {
+    // Initial step: Badge type selection
+    await driver.wait(until.elementLocated(By.css('[href*="learningpaths/create"]')), defaultWait);
+
+    const selectedBadgeType = await driver.findElement(
+        By.css('[href*="learningpaths/create"]')
+    );
+    await selectedBadgeType.click();
+
+    // Next step: Badge details
+    // Title field
+    const titleField = await driver.findElement(By.css(
+        'input[type="text"]'));
+    await titleField.sendKeys(microDegreeTitle);
+
+    // Description
+    const descriptionField = await driver.findElement(By.css(
+        'textarea'));
+    await descriptionField.sendKeys(microDegreeDescription);
+    
+    // Image field
+    // Testing switching between framed and unframed/owned images is essential as users might experience some issues while doing so
+    // 1. Upload own image (insterted into badge frame)
+    await uploadImage(driver, "image_field", 0, testImagePath);
+    // 2. Upload own image
+    await uploadImage(driver, "image_field", 1, testImagePath);
+    // 3. Select an image from nounproject
+    await selectNounProjectImage(driver, nounProjectSearchText);
+
+    // Click next button to move to the next step
+    const nextButton = await driver.findElement(
+        ExtendedBy.tagWithText('span', 'Weiter'));
+    await nextButton.click();
+
+    // Next step: Add badges to the micro degree
+    const selectableCards = await driver.findElements(
+        ExtendedBy.containingText(
+            By.css('bg-badgecard'),
+            By.css('a'),
+            testBadgeTitle
+        ));
+
+    for(let i = 0; i < n; i++)
+        await selectableCards[i].findElement(By.css('hlm-checkbox')).click();
+
+    // Next step: Order of the badges
+    await nextButton.click();
+    const badgeCards = await driver.findElements(By.css(
+        'bg-badgecard'
+    ));
+    const dragAndDropAction = new Actions(driver)
+        .dragAndDrop(badgeCards[0], badgeCards.at(-1));
+    await dragAndDropAction.perform();
+
+    // Next step: Tag and Create
+    await nextButton.click();
+    await addNewTag(driver, tagName);   
+    
+    const submitForm = await driver.findElement(By.css('form'));
+    await submitForm.submit();
+
+    // The regular expression is for urls of the micro degree like 
+    // '/issuer/issuers/{issuerID}/learningpaths/{learningpathID}'
+    // but it disallows the path where the micro degree is created:
+    // '/issuer/issuers/{issuerID}/learningpaths/create'
+    await driver.wait(until.urlMatches(/\/issuer\/issuers\/[^\/]+\/learningpaths\/(?!create$)[^\/]+$/), extendedWait);
+}
+
+/**
+ * Deletes the micro degree using api requests without UI
+ */
+export const deleteMicroDegreeOverApi =
+    () => deleteBadgeOverApi(microDegreeTitle);
+
+/**
+ * Deletes the given number of badges via the api, essentially
+ * calling {@link deleteBadgeOverApi} multiple times.
+ * @param {number} n The number of badges to delete
+ */
+export async function deleteBadgesOverApi(n) {
+    for(let i = 0; i < n; i++)
path: test/badge.js
user: login: scheidtdav
body: That is actually making things more difficult right now :sweat_smile:
It would require changes to pretty much all functions that use the title right now. Plus changing how some elements are selected (e.g. the checkboxes for the badges to consider for a micro degree).

It doesn't feel right, I agree, but will it give us any benefit at this point?
created_at: 2025-04-23T14:34:14Z
author_association: MEMBER
diff_hunk: @@ -113,7 +123,65 @@ describe('Badge Test', function() {
 
     it('delete the competency badge', async function() {
         await deleteBadgeOverApi();
+    });
+
+    // micro degree
+    it('should create a micro degree', async () => {
+        // We need at least three badges to put as part of the degree
+        await createBadges(driver, BADGES_FOR_MICRO_DEGREE);
+        await navigateToBadgeCreation(driver);
+        await createMicroDegree(driver, BADGES_FOR_MICRO_DEGREE);
+    })
+    .timeout(this.timeout() + BADGES_FOR_MICRO_DEGREE * 10_000); // Allow larger timeout since a number of badges have to be created
+
+    it('should award the micro degree', async function() {
+        // users get micro degrees automatically by receiving all badges
+        // contained in the degree itself
+        for(let i = 0; i < BADGES_FOR_MICRO_DEGREE; i++) {
+            await navigateToBadgeAwarding(driver, i);
+            await awardBadge(driver);
+        }
+    })
+    .timeout(this.timeout() + BADGES_FOR_MICRO_DEGREE * 10_000); // Allow larger timeout since awarding badges takes a while
+
+    it('should receive the micro degree', async function() {
path: test/badge.spec.js
user: login: timber-they
body: Do you create a ticket for it?
created_at: 2025-04-25T13:47:46Z
author_association: CONTRIBUTOR
diff_hunk: @@ -458,3 +573,105 @@ export async function verifyBadgeOverApi() {
     // Study-load is in minutes while test-duration is in hours
     assert.equal(studyLoad/60, testDuration);
 }
+
+/**
+ * Creates {@link n} number of  badges
+ * @param {import('selenium-webdriver').ThenableWebDriver} driver 
+ * @param {number} n Number of badges to create
+ */
+export async function createBadges(driver, n) {
+    for(let i = 0; i < n; i++) {
+        await navigateToBadgeCreation(driver);
+        await createBadge(driver);
+    }
+}
+
+/**
+ * Creates a micro degree that includes the first {@link n} badges
+ * available to the logged in user
+ * @param {import('selenium-webdriver').ThenableWebDriver} driver 
+ * @param {number} n Number of badges to include
+ */
+export async function createMicroDegree(driver, n) {
+    // Initial step: Badge type selection
+    await driver.wait(until.elementLocated(By.css('[href*="learningpaths/create"]')), defaultWait);
+
+    const selectedBadgeType = await driver.findElement(
+        By.css('[href*="learningpaths/create"]')
+    );
+    await selectedBadgeType.click();
+
+    // Next step: Badge details
+    // Title field
+    const titleField = await driver.findElement(By.css(
+        'input[type="text"]'));
+    await titleField.sendKeys(microDegreeTitle);
+
+    // Description
+    const descriptionField = await driver.findElement(By.css(
+        'textarea'));
+    await descriptionField.sendKeys(microDegreeDescription);
+    
+    // Image field
+    // Testing switching between framed and unframed/owned images is essential as users might experience some issues while doing so
+    // 1. Upload own image (insterted into badge frame)
+    await uploadImage(driver, "image_field", 0, testImagePath);
+    // 2. Upload own image
+    await uploadImage(driver, "image_field", 1, testImagePath);
+    // 3. Select an image from nounproject
+    await selectNounProjectImage(driver, nounProjectSearchText);
+
+    // Click next button to move to the next step
+    const nextButton = await driver.findElement(
+        ExtendedBy.tagWithText('span', 'Weiter'));
+    await nextButton.click();
+
+    // Next step: Add badges to the micro degree
+    const selectableCards = await driver.findElements(
+        ExtendedBy.containingText(
+            By.css('bg-badgecard'),
+            By.css('a'),
+            testBadgeTitle
+        ));
+
+    for(let i = 0; i < n; i++)
+        await selectableCards[i].findElement(By.css('hlm-checkbox')).click();
+
+    // Next step: Order of the badges
+    await nextButton.click();
+    const badgeCards = await driver.findElements(By.css(
+        'bg-badgecard'
+    ));
+    const dragAndDropAction = new Actions(driver)
+        .dragAndDrop(badgeCards[0], badgeCards.at(-1));
+    await dragAndDropAction.perform();
+
+    // Next step: Tag and Create
+    await nextButton.click();
+    await addNewTag(driver, tagName);   
+    
+    const submitForm = await driver.findElement(By.css('form'));
+    await submitForm.submit();
+
+    // The regular expression is for urls of the micro degree like 
+    // '/issuer/issuers/{issuerID}/learningpaths/{learningpathID}'
+    // but it disallows the path where the micro degree is created:
+    // '/issuer/issuers/{issuerID}/learningpaths/create'
+    await driver.wait(until.urlMatches(/\/issuer\/issuers\/[^\/]+\/learningpaths\/(?!create$)[^\/]+$/), extendedWait);
+}
+
+/**
+ * Deletes the micro degree using api requests without UI
+ */
+export const deleteMicroDegreeOverApi =
+    () => deleteBadgeOverApi(microDegreeTitle);
+
+/**
+ * Deletes the given number of badges via the api, essentially
+ * calling {@link deleteBadgeOverApi} multiple times.
+ * @param {number} n The number of badges to delete
+ */
+export async function deleteBadgesOverApi(n) {
+    for(let i = 0; i < n; i++)
path: test/badge.js
user: login: timber-they
body: I guess it would have been a bit cleaner. But as long as it works I don't think it's a big deal
created_at: 2025-04-25T13:48:44Z
author_association: CONTRIBUTOR

PR comments:
user: login: YouQam
created_at: 2025-03-14T09:39:40Z
body: @timber-they I put it back to WIP as we decided to add a test for micro degree creation
author_association: CONTRIBUTOR
user: login: timber-they
created_at: 2025-03-25T11:34:25Z
body: @YouQam what is the status here? It would be good to merge at least the fixes from this PR to `main` soon, since the current state on `main` is not functional
author_association: CONTRIBUTOR
user: login: scheidtdav
created_at: 2025-04-22T10:52:34Z
body: @timber-they Thanks again for you input on this!
I went through it and also added the tests for micro degrees.
Could you give it a final look pls? :-)
author_association: MEMBER
user: login: timber-they
created_at: 2025-04-23T12:14:07Z
body: @scheidtdav thanks. If a PR is ready for review (again) you can also mark it as not a draft anymore (I did this now).
author_association: CONTRIBUTOR
user: login: scheidtdav
created_at: 2025-04-23T13:12:16Z
body: > I find it a bit unlucky that instead of first fixing the changes I requested, you included the implementation of a whole different feature into this PR. If you hadn't done this, I could probably now merge this PR

Do you mean the test for the micro degree? 
My understanding was, that this was the reason for this still being open? (https://github.com/mint-o-badges/oeb-test/pull/13#issuecomment-2724166331)
author_association: MEMBER
user: login: timber-they
created_at: 2025-04-23T13:30:09Z
body: > > I find it a bit unlucky that instead of first fixing the changes I requested, you included the implementation of a whole different feature into this PR. If you hadn't done this, I could probably now merge this PR
> 
> Do you mean the test for the micro degree? My understanding was, that this was the reason for this still being open? ([#13 (comment)](https://github.com/mint-o-badges/oeb-test/pull/13#issuecomment-2724166331))

If that's so, why did you request a review before the tests for the micro degree were implemented then? At that point I expected the microdegrees to be a separate issue
author_association: CONTRIBUTOR
user: login: scheidtdav
created_at: 2025-04-23T14:46:28Z
body: Yeah fair. I remember picking this up thinking I just need to fix the existing tests to use the stepper.
I oversaw the comment I mentioned and @zven pointed this out after I asked for the first review.

With the feedback you gave from the first review to now, it was probably good that we started early.
Your time on this is appreciated!

I tried reacting fast to your latest feedback. Let me know if this is alright now!
Thanks again!
author_association: MEMBER