From af78d57d0a02a5f0c38c6a1387b4f516c6e1e244 Mon Sep 17 00:00:00 2001 From: Karoline Tufte Lien Date: Thu, 3 Sep 2026 12:48:35 +0200 Subject: [PATCH 1/7] chore: de-flake the RelatedStages Cypress spec The two link and unlink scenarios assert that the schedule and enter details actions are disabled. Those actions are disabled only when the Baby Postnatal stage already has an event, since useCanAddNewEventToStage returns repeatable || existingRelatedEvents.length === 0 and both Child Programme stages are non-repeatable. Neither scenario creates that event, so both depend on one being left behind by an earlier run. For enrollment EOxeNf2MdBf that dependency is circular: the next scenario in the file deletes every event of the same enrollment and re-creates them through the UI, so when it fails part way through, the following run fails in the link and unlink scenario instead, with an input that never becomes disabled. That is the shard 2 flake. Seed the event through the API instead, guarded by a check for an existing one, which is also the only case where creating it is legal for a non-repeatable stage. Both link and unlink scenarios now state the fixture they need rather than inheriting it. Also make the two event deletion steps idempotent: the reload only happened when there was something to delete, so a re-attempt kept the pre-deletion page, and neither step noticed an import that came back with errors. Co-Authored-By: Claude Opus 5 (1M context) --- .../e2e/RelatedStages/RelatedStages.feature | 4 +- cypress/e2e/RelatedStages/RelatedStages.js | 74 ++++++++++++++----- 2 files changed, 59 insertions(+), 19 deletions(-) diff --git a/cypress/e2e/RelatedStages/RelatedStages.feature b/cypress/e2e/RelatedStages/RelatedStages.feature index 125c1968f6..bdf2ae945b 100644 --- a/cypress/e2e/RelatedStages/RelatedStages.feature +++ b/cypress/e2e/RelatedStages/RelatedStages.feature @@ -2,6 +2,7 @@ Feature: Related stages Scenario: Edit event -> User is able to link and unlink an existing event Given you make sure the event TwoGi1mUFFw is unlinked + And you make sure the enrollment ZlWJ5HwZLeQ has a Baby Postnatal event And you land on a enrollment page domain by having typed #/enrollmentEventEdit?eventId=TwoGi1mUFFw&orgUnitId=lyONqUkY1Bq And the Related stages Actions is visible at the bottom of the page And the schedule and enter details actions are disabled @@ -42,7 +43,8 @@ Feature: Related stages And you can see the Baby postnatal new event form Scenario: New event -> User is able to link and unlink an existing event - Given you land on a enrollment page domain by having typed #/enrollment?enrollmentId=EOxeNf2MdBf&orgUnitId=VFF7f43dJv4&programId=IpHINAT79UW&teiId=QhoMgzeGuGq + Given you make sure the enrollment EOxeNf2MdBf has a Baby Postnatal event + And you land on a enrollment page domain by having typed #/enrollment?enrollmentId=EOxeNf2MdBf&orgUnitId=VFF7f43dJv4&programId=IpHINAT79UW&teiId=QhoMgzeGuGq And you delete the Birth event And you open the Birth new event page and fill in the required data in the form And the Related stages Actions is visible at the bottom of the page diff --git a/cypress/e2e/RelatedStages/RelatedStages.js b/cypress/e2e/RelatedStages/RelatedStages.js index 85d13507e1..83f3ae258e 100644 --- a/cypress/e2e/RelatedStages/RelatedStages.js +++ b/cypress/e2e/RelatedStages/RelatedStages.js @@ -1,11 +1,42 @@ import { Given, Then, When, defineStep as And } from '@badeball/cypress-cucumber-preprocessor'; import { getCurrentYear } from '../../support/date'; +const BABY_POSTNATAL_STAGE = 'ZzYYXq4fJie'; +const BIRTH_STAGE = 'A03MvHHogjR'; + Given(/^you land on a enrollment page domain by having typed (.*)$/, (url) => { cy.visit(url); cy.get('[data-test="person-selector-container"]').contains('Person'); }); +Given(/^you make sure the enrollment (.+) has a Baby Postnatal event$/, (enrollmentId) => { + cy.buildApiUrl('tracker', `enrollments/${enrollmentId}?fields=orgUnit,program,events[event,programStage]`) + .then(url => cy.request(url)) + .then(({ body }) => { + const events = body.events ?? []; + + if (events.some(({ programStage }) => programStage === BABY_POSTNATAL_STAGE)) { + return undefined; + } + + return cy + .buildApiUrl('tracker?async=false&importStrategy=CREATE_AND_UPDATE') + .then(createUrl => cy.request('POST', createUrl, { + events: [{ + program: body.program, + programStage: BABY_POSTNATAL_STAGE, + enrollment: enrollmentId, + orgUnit: body.orgUnit, + occurredAt: `${getCurrentYear()}-07-01`, + status: 'ACTIVE', + }], + })) + .then(({ body: importSummary }) => { + expect(importSummary.status).to.eq('OK'); + }); + }); +}); + Given(/^you make sure the event (.+) is unlinked$/, (eventId) => { cy.buildApiUrl('tracker', `events/${eventId}?fields=relationships[relationship]`) .then(url => cy.request(url)) @@ -85,18 +116,20 @@ And('you delete the Birth event', () => { cy.buildApiUrl('tracker', 'enrollments/EOxeNf2MdBf?fields=events[event,programStage]') .then(url => cy.request(url)) .then(({ body }) => { - const { events } = body; - - if (events) { - const eventToDelete = events.find(event => event.programStage === 'A03MvHHogjR'); - if (eventToDelete) { - cy.buildApiUrl('tracker?async=false&importStrategy=DELETE').then((eventUrl) => { - cy.request('POST', eventUrl, { events: [eventToDelete] }); - cy.reload(); - }); - } + const eventToDelete = (body.events ?? []).find(event => event.programStage === BIRTH_STAGE); + + if (!eventToDelete) { + return undefined; } - }); + + return cy + .buildApiUrl('tracker?async=false&importStrategy=DELETE') + .then(eventUrl => cy.request('POST', eventUrl, { events: [eventToDelete] })) + .then(({ body: importSummary }) => { + expect(importSummary.status).to.eq('OK'); + }); + }) + .then(() => cy.reload()); }); And('you open the Birth event edit page', () => { @@ -265,14 +298,19 @@ And(/^you delete the events of the enrollmentId (.*)$/, (enrollmentId) => { cy.buildApiUrl('tracker', `enrollments/${enrollmentId}?fields=events[event]`) .then(url => cy.request(url)) .then(({ body }) => { - const { events } = body; + const events = body.events ?? []; - if (events) { - cy.buildApiUrl('tracker?async=false&importStrategy=DELETE').then((eventUrl) => { - cy.request('POST', eventUrl, { events }); - cy.reload(); - }); + if (!events.length) { + return undefined; } - }); + + return cy + .buildApiUrl('tracker?async=false&importStrategy=DELETE') + .then(eventUrl => cy.request('POST', eventUrl, { events })) + .then(({ body: importSummary }) => { + expect(importSummary.status).to.eq('OK'); + }); + }) + .then(() => cy.reload()); }); From 745010c4db578348eeb7819f7dc930fef1b4bdae Mon Sep 17 00:00:00 2001 From: Karoline Tufte Lien Date: Fri, 4 Sep 2026 11:59:37 +0200 Subject: [PATCH 2/7] chore: guard linkability in the RelatedStages seed step MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The seed guard checked only that a Baby Postnatal event exists. Both link-and-unlink scenarios then pick that event out of the "Link to an existing event" list, which the widget builds from linkableEvents — events not already carrying a relationship of that type. A run that dies between the Link click and the closing unlink leaves the event linked, so the next run would skip seeding on existence alone and fail on an empty list. The step now also strips the event's relationships, through the same code path as the unlink step, and is renamed to say so. Collapse the four tracker writes in the file onto one importTracker helper that asserts status === 'OK', replacing three near-identical inline blocks and covering the unlink step, which had no assertion. Seed with CREATE rather than CREATE_AND_UPDATE, since the guard has already established the event is absent. Co-Authored-By: Claude Opus 5 (1M context) --- .../e2e/RelatedStages/RelatedStages.feature | 4 +- cypress/e2e/RelatedStages/RelatedStages.js | 83 +++++++++---------- 2 files changed, 42 insertions(+), 45 deletions(-) diff --git a/cypress/e2e/RelatedStages/RelatedStages.feature b/cypress/e2e/RelatedStages/RelatedStages.feature index bdf2ae945b..af59b71162 100644 --- a/cypress/e2e/RelatedStages/RelatedStages.feature +++ b/cypress/e2e/RelatedStages/RelatedStages.feature @@ -2,7 +2,7 @@ Feature: Related stages Scenario: Edit event -> User is able to link and unlink an existing event Given you make sure the event TwoGi1mUFFw is unlinked - And you make sure the enrollment ZlWJ5HwZLeQ has a Baby Postnatal event + And you make sure the enrollment ZlWJ5HwZLeQ has a linkable Baby Postnatal event And you land on a enrollment page domain by having typed #/enrollmentEventEdit?eventId=TwoGi1mUFFw&orgUnitId=lyONqUkY1Bq And the Related stages Actions is visible at the bottom of the page And the schedule and enter details actions are disabled @@ -43,7 +43,7 @@ Feature: Related stages And you can see the Baby postnatal new event form Scenario: New event -> User is able to link and unlink an existing event - Given you make sure the enrollment EOxeNf2MdBf has a Baby Postnatal event + Given you make sure the enrollment EOxeNf2MdBf has a linkable Baby Postnatal event And you land on a enrollment page domain by having typed #/enrollment?enrollmentId=EOxeNf2MdBf&orgUnitId=VFF7f43dJv4&programId=IpHINAT79UW&teiId=QhoMgzeGuGq And you delete the Birth event And you open the Birth new event page and fill in the required data in the form diff --git a/cypress/e2e/RelatedStages/RelatedStages.js b/cypress/e2e/RelatedStages/RelatedStages.js index 83f3ae258e..ab6c6ca38e 100644 --- a/cypress/e2e/RelatedStages/RelatedStages.js +++ b/cypress/e2e/RelatedStages/RelatedStages.js @@ -4,52 +4,59 @@ import { getCurrentYear } from '../../support/date'; const BABY_POSTNATAL_STAGE = 'ZzYYXq4fJie'; const BIRTH_STAGE = 'A03MvHHogjR'; +const importTracker = (importStrategy, payload) => + cy.buildApiUrl(`tracker?async=false&importStrategy=${importStrategy}`) + .then(url => cy.request('POST', url, payload)) + .then(({ body }) => { + expect(body.status).to.eq('OK'); + }); + +const unlinkEvent = eventId => + cy.buildApiUrl('tracker', `events/${eventId}?fields=relationships[relationship]`) + .then(url => cy.request(url)) + .then(({ body }) => { + const relationships = body.relationships ?? []; + + if (!relationships.length) { + return undefined; + } + + return importTracker('DELETE', { + relationships: relationships.map(({ relationship }) => ({ relationship })), + }); + }); + Given(/^you land on a enrollment page domain by having typed (.*)$/, (url) => { cy.visit(url); cy.get('[data-test="person-selector-container"]').contains('Person'); }); -Given(/^you make sure the enrollment (.+) has a Baby Postnatal event$/, (enrollmentId) => { +Given(/^you make sure the enrollment (.+) has a linkable Baby Postnatal event$/, (enrollmentId) => { cy.buildApiUrl('tracker', `enrollments/${enrollmentId}?fields=orgUnit,program,events[event,programStage]`) .then(url => cy.request(url)) .then(({ body }) => { - const events = body.events ?? []; + const existingEvent = (body.events ?? []) + .find(({ programStage }) => programStage === BABY_POSTNATAL_STAGE); - if (events.some(({ programStage }) => programStage === BABY_POSTNATAL_STAGE)) { - return undefined; + if (existingEvent) { + return unlinkEvent(existingEvent.event); } - return cy - .buildApiUrl('tracker?async=false&importStrategy=CREATE_AND_UPDATE') - .then(createUrl => cy.request('POST', createUrl, { - events: [{ - program: body.program, - programStage: BABY_POSTNATAL_STAGE, - enrollment: enrollmentId, - orgUnit: body.orgUnit, - occurredAt: `${getCurrentYear()}-07-01`, - status: 'ACTIVE', - }], - })) - .then(({ body: importSummary }) => { - expect(importSummary.status).to.eq('OK'); - }); + return importTracker('CREATE', { + events: [{ + program: body.program, + programStage: BABY_POSTNATAL_STAGE, + enrollment: enrollmentId, + orgUnit: body.orgUnit, + occurredAt: `${getCurrentYear()}-07-01`, + status: 'ACTIVE', + }], + }); }); }); Given(/^you make sure the event (.+) is unlinked$/, (eventId) => { - cy.buildApiUrl('tracker', `events/${eventId}?fields=relationships[relationship]`) - .then(url => cy.request(url)) - .then(({ body }) => { - const relationships = body.relationships ?? []; - if (relationships.length) { - cy.buildApiUrl('tracker?async=false&importStrategy=DELETE').then((deleteUrl) => { - cy.request('POST', deleteUrl, { - relationships: relationships.map(({ relationship }) => ({ relationship })), - }); - }); - } - }); + unlinkEvent(eventId); }); And(/^the Related stages Actions is ?(.*) visible at the bottom of the page/, (not) => { @@ -122,12 +129,7 @@ And('you delete the Birth event', () => { return undefined; } - return cy - .buildApiUrl('tracker?async=false&importStrategy=DELETE') - .then(eventUrl => cy.request('POST', eventUrl, { events: [eventToDelete] })) - .then(({ body: importSummary }) => { - expect(importSummary.status).to.eq('OK'); - }); + return importTracker('DELETE', { events: [eventToDelete] }); }) .then(() => cy.reload()); }); @@ -304,12 +306,7 @@ And(/^you delete the events of the enrollmentId (.*)$/, (enrollmentId) => { return undefined; } - return cy - .buildApiUrl('tracker?async=false&importStrategy=DELETE') - .then(eventUrl => cy.request('POST', eventUrl, { events })) - .then(({ body: importSummary }) => { - expect(importSummary.status).to.eq('OK'); - }); + return importTracker('DELETE', { events }); }) .then(() => cy.reload()); }); From 91cc95c8eaf3a6c43c467392513f2cf19bb3943f Mon Sep 17 00:00:00 2001 From: Karoline Tufte Lien Date: Fri, 4 Sep 2026 15:15:37 +0200 Subject: [PATCH 3/7] chore: clean up the enrolled tracked entity through the API The two Enroll trackedEntity scenarios created a real tracked entity and deleted it again through the UI as their last step, so a failure anywhere earlier in the scenario leaked the record - once per retry attempt. Move that cleanup into an After hook that deletes through the API, which runs even when the scenario fails, following the pattern from #4714. The generated first name is captured in a module variable so the hook can look the record up, and uses Date.now() so parallel shards cannot match each other's records. Also drop the linkability half of the seed guard. It was added for a state that turns out to be unreachable: deleting an event cascades to its relationships, verified directly against play dev - link a Birth event to its Baby Postnatal event, delete the Birth event, and the related event comes back with zero relationships. So a run that dies mid-scenario cannot leave a Baby Postnatal event that is present but unlinkable; the Birth deletion in the New event scenario and the existing unlink step in the Edit event scenario each clear it. The guard is back to checking existence, which is the state the assertion actually reads. Co-Authored-By: Claude Opus 5 (1M context) --- .../e2e/RelatedStages/RelatedStages.feature | 8 +-- cypress/e2e/RelatedStages/RelatedStages.js | 66 ++++++++++++------- 2 files changed, 47 insertions(+), 27 deletions(-) diff --git a/cypress/e2e/RelatedStages/RelatedStages.feature b/cypress/e2e/RelatedStages/RelatedStages.feature index af59b71162..72b40fa68c 100644 --- a/cypress/e2e/RelatedStages/RelatedStages.feature +++ b/cypress/e2e/RelatedStages/RelatedStages.feature @@ -2,7 +2,7 @@ Feature: Related stages Scenario: Edit event -> User is able to link and unlink an existing event Given you make sure the event TwoGi1mUFFw is unlinked - And you make sure the enrollment ZlWJ5HwZLeQ has a linkable Baby Postnatal event + And you make sure the enrollment ZlWJ5HwZLeQ has a Baby Postnatal event And you land on a enrollment page domain by having typed #/enrollmentEventEdit?eventId=TwoGi1mUFFw&orgUnitId=lyONqUkY1Bq And the Related stages Actions is visible at the bottom of the page And the schedule and enter details actions are disabled @@ -43,7 +43,7 @@ Feature: Related stages And you can see the Baby postnatal new event form Scenario: New event -> User is able to link and unlink an existing event - Given you make sure the enrollment EOxeNf2MdBf has a linkable Baby Postnatal event + Given you make sure the enrollment EOxeNf2MdBf has a Baby Postnatal event And you land on a enrollment page domain by having typed #/enrollment?enrollmentId=EOxeNf2MdBf&orgUnitId=VFF7f43dJv4&programId=IpHINAT79UW&teiId=QhoMgzeGuGq And you delete the Birth event And you open the Birth new event page and fill in the required data in the form @@ -81,6 +81,7 @@ Feature: Related stages Then you can see the Birth linked event And you can see the Baby postnatal new event form + @with-tracked-entity-cleanup Scenario: Enroll trackedEntity -> User is able to schedule an event in a different orgUnit Given you are in Child programme and Tombo Wallah CHP organization unit registration page When you fill the Child Program program registration form with unique values @@ -88,8 +89,8 @@ Feature: Related stages And you fill in the required values for the Baby postnatal event when scheduling And you click the save person submit button Then you are redirect to the enrollment dasboard and you see the 2 linked events in different orgUnits - And you delete the recently added tracked entity + @with-tracked-entity-cleanup Scenario: Enroll trackedEntity -> User is able to enter details in a different orgUnit Given you are in Child programme and Tombo Wallah CHP organization unit registration page When you fill the Child Program program registration form with unique values @@ -100,4 +101,3 @@ Feature: Related stages And you can see the Baby postnatal new event form And you navigate to the Enrollment dashboard And you are redirect to the enrollment dasboard and you see the 2 linked events in different orgUnits - And you delete the recently added tracked entity \ No newline at end of file diff --git a/cypress/e2e/RelatedStages/RelatedStages.js b/cypress/e2e/RelatedStages/RelatedStages.js index ab6c6ca38e..afab435bf7 100644 --- a/cypress/e2e/RelatedStages/RelatedStages.js +++ b/cypress/e2e/RelatedStages/RelatedStages.js @@ -1,8 +1,12 @@ -import { Given, Then, When, defineStep as And } from '@badeball/cypress-cucumber-preprocessor'; +import { After, Given, Then, When, defineStep as And } from '@badeball/cypress-cucumber-preprocessor'; import { getCurrentYear } from '../../support/date'; const BABY_POSTNATAL_STAGE = 'ZzYYXq4fJie'; const BIRTH_STAGE = 'A03MvHHogjR'; +const CHILD_PROGRAMME = 'IpHINAT79UW'; +const FIRST_NAME_ATTRIBUTE = 'w75KJ2mc4zz'; + +let enrolledEntityFirstName; const importTracker = (importStrategy, payload) => cy.buildApiUrl(`tracker?async=false&importStrategy=${importStrategy}`) @@ -26,20 +30,49 @@ const unlinkEvent = eventId => }); }); +const clearEnrolledEntity = () => { + if (!enrolledEntityFirstName) { + return undefined; + } + + return cy + .buildApiUrl( + 'tracker', + `trackedEntities?program=${CHILD_PROGRAMME}&orgUnitMode=ACCESSIBLE` + + `&filter=${FIRST_NAME_ATTRIBUTE}:eq:${enrolledEntityFirstName}` + + '&fields=trackedEntity&page=1&pageSize=5', + ) + .then(url => cy.request(url)) + .then(({ body }) => { + const apiTrackedEntities = body.trackedEntities || body.instances || []; + const trackedEntities = apiTrackedEntities.map(({ trackedEntity }) => ({ trackedEntity })); + + if (!trackedEntities.length) { + return undefined; + } + + return importTracker('DELETE', { trackedEntities }); + }); +}; + +After({ tags: '@with-tracked-entity-cleanup' }, () => { + clearEnrolledEntity(); + enrolledEntityFirstName = undefined; +}); + Given(/^you land on a enrollment page domain by having typed (.*)$/, (url) => { cy.visit(url); cy.get('[data-test="person-selector-container"]').contains('Person'); }); -Given(/^you make sure the enrollment (.+) has a linkable Baby Postnatal event$/, (enrollmentId) => { +Given(/^you make sure the enrollment (.+) has a Baby Postnatal event$/, (enrollmentId) => { cy.buildApiUrl('tracker', `enrollments/${enrollmentId}?fields=orgUnit,program,events[event,programStage]`) .then(url => cy.request(url)) .then(({ body }) => { - const existingEvent = (body.events ?? []) - .find(({ programStage }) => programStage === BABY_POSTNATAL_STAGE); + const events = body.events ?? []; - if (existingEvent) { - return unlinkEvent(existingEvent.event); + if (events.some(({ programStage }) => programStage === BABY_POSTNATAL_STAGE)) { + return undefined; } return importTracker('CREATE', { @@ -242,6 +275,8 @@ Then('you are redirect to the enrollment dasboard and you see the 2 linked event }); And('you fill the Child Program program registration form with unique values', () => { + enrolledEntityFirstName = `Sarah-${Date.now()}`; + cy.get('input[type="text"]') .eq(1) .type('2021-01-01') @@ -255,11 +290,11 @@ And('you fill the Child Program program registration form with unique values', ( .blur(); cy.get('input[type="text"]') .eq(4) - .type(`Sarah-${Math.round((new Date()).getTime() / 1000)}`) + .type(enrolledEntityFirstName) .blur(); cy.get('input[type="text"]') .eq(5) - .type(`Beth-${Math.round((new Date()).getTime() / 1000)}`) + .type(`Beth-${Date.now()}`) .blur(); cy.get('input[type="text"]') .eq(7) @@ -267,21 +302,6 @@ And('you fill the Child Program program registration form with unique values', ( .blur(); }); -And('you delete the recently added tracked entity', () => { - cy.get('[data-test="profile-widget"]') - .contains('Person profile') - .should('exist'); - cy.get('[data-test="tracked-entity-profile-overflow-button"]') - .click(); - cy.contains('Delete Person') - .click(); - cy.get('[data-test="widget-profile-delete-modal"]').within(() => { - cy.contains('Yes, delete Person') - .click(); - }); - cy.url().should('include', 'selectedTemplateId=IpHINAT79UW'); -}); - And(/^you click the save (.*) submit button$/, (TEType) => { cy.contains(`Save ${TEType}`) .click(); From 1f4fd316f4274df3284712c8757c7db90a374b74 Mon Sep 17 00:00:00 2001 From: Karoline Tufte Lien Date: Mon, 7 Sep 2026 15:50:55 +0200 Subject: [PATCH 4/7] chore: guard the assignee scenarios against a leftover assignment Both assignee scenarios assign a user and remove them again as their last step, so a run that dies in between leaves the event assigned. The Assign button only renders when nobody is assigned - DisplayMode.component.tsx branches on `assignee`, showing Edit instead - so every later run fails looking for `widget-assignee-assign`, on every branch, until someone clears the assignment by hand. That is the state the shared dev instance is in now: the same failure reproduces on other open pull requests. Add `you make sure the event has no assigned user`, which clears the assignment through the API by re-importing the event without `assignedUser`. Verified against play stable-2-43-1: with Geetha assigned to z4PCgdaBXFh, the spec passes 12/12 with the guard and fails with the exact CI assertion without it. Both scenarios get the guard - the edit mode one leaks the same way and has only been lucky so far. Move the tracker import helper out of RelatedStages.js into a cy.importTracker command in cypress/support/commands.js, since the guard needs it too. Co-Authored-By: Claude Opus 5 (1M context) --- cypress/e2e/RelatedStages/RelatedStages.js | 17 +++++------------ .../WidgetAssignee/index.js | 18 +++++++++++++++++- .../WidgetsForEnrollmentEditEvent.feature | 6 ++++-- cypress/support/commands.js | 8 ++++++++ 4 files changed, 34 insertions(+), 15 deletions(-) diff --git a/cypress/e2e/RelatedStages/RelatedStages.js b/cypress/e2e/RelatedStages/RelatedStages.js index afab435bf7..6895893a3d 100644 --- a/cypress/e2e/RelatedStages/RelatedStages.js +++ b/cypress/e2e/RelatedStages/RelatedStages.js @@ -8,13 +8,6 @@ const FIRST_NAME_ATTRIBUTE = 'w75KJ2mc4zz'; let enrolledEntityFirstName; -const importTracker = (importStrategy, payload) => - cy.buildApiUrl(`tracker?async=false&importStrategy=${importStrategy}`) - .then(url => cy.request('POST', url, payload)) - .then(({ body }) => { - expect(body.status).to.eq('OK'); - }); - const unlinkEvent = eventId => cy.buildApiUrl('tracker', `events/${eventId}?fields=relationships[relationship]`) .then(url => cy.request(url)) @@ -25,7 +18,7 @@ const unlinkEvent = eventId => return undefined; } - return importTracker('DELETE', { + return cy.importTracker('DELETE', { relationships: relationships.map(({ relationship }) => ({ relationship })), }); }); @@ -51,7 +44,7 @@ const clearEnrolledEntity = () => { return undefined; } - return importTracker('DELETE', { trackedEntities }); + return cy.importTracker('DELETE', { trackedEntities }); }); }; @@ -75,7 +68,7 @@ Given(/^you make sure the enrollment (.+) has a Baby Postnatal event$/, (enrollm return undefined; } - return importTracker('CREATE', { + return cy.importTracker('CREATE', { events: [{ program: body.program, programStage: BABY_POSTNATAL_STAGE, @@ -162,7 +155,7 @@ And('you delete the Birth event', () => { return undefined; } - return importTracker('DELETE', { events: [eventToDelete] }); + return cy.importTracker('DELETE', { events: [eventToDelete] }); }) .then(() => cy.reload()); }); @@ -326,7 +319,7 @@ And(/^you delete the events of the enrollmentId (.*)$/, (enrollmentId) => { return undefined; } - return importTracker('DELETE', { events }); + return cy.importTracker('DELETE', { events }); }) .then(() => cy.reload()); }); diff --git a/cypress/e2e/WidgetsForEnrollmentPages/WidgetAssignee/index.js b/cypress/e2e/WidgetsForEnrollmentPages/WidgetAssignee/index.js index ea9f9fb411..fbfca19186 100644 --- a/cypress/e2e/WidgetsForEnrollmentPages/WidgetAssignee/index.js +++ b/cypress/e2e/WidgetsForEnrollmentPages/WidgetAssignee/index.js @@ -1,4 +1,20 @@ -import { When, Then } from '@badeball/cypress-cucumber-preprocessor'; +import { Given, When, Then } from '@badeball/cypress-cucumber-preprocessor'; + +const EVENT_FIELDS = 'event,program,programStage,enrollment,orgUnit,occurredAt,scheduledAt,status,dataValues'; + +Given(/^you make sure the event (.+) has no assigned user$/, (eventId) => { + cy.buildApiUrl('tracker', `events/${eventId}?fields=${EVENT_FIELDS},assignedUser`) + .then(url => cy.request(url)) + .then(({ body }) => { + const { assignedUser, ...event } = body; + + if (!assignedUser) { + return undefined; + } + + return cy.importTracker('UPDATE', { events: [event] }); + }); +}); When('you assign the user Geetha in the view mode', () => { cy.get('[data-test="widget-assignee"]').within(() => { diff --git a/cypress/e2e/WidgetsForEnrollmentPages/WidgetsForEnrollmentEditEvent/WidgetsForEnrollmentEditEvent.feature b/cypress/e2e/WidgetsForEnrollmentPages/WidgetsForEnrollmentEditEvent/WidgetsForEnrollmentEditEvent.feature index 896acfb495..f370b5ef17 100644 --- a/cypress/e2e/WidgetsForEnrollmentPages/WidgetsForEnrollmentEditEvent/WidgetsForEnrollmentEditEvent.feature +++ b/cypress/e2e/WidgetsForEnrollmentPages/WidgetsForEnrollmentEditEvent/WidgetsForEnrollmentEditEvent.feature @@ -58,14 +58,16 @@ Feature: The user interacts with the widgets on the enrollment edit event Then list should contain the new note: edit mode note Scenario: You can assign a user to a event in the view mode - Given you land on the enrollment edit event page by having typed /#/enrollmentEventEdit?eventId=z4PCgdaBXFh&orgUnitId=g8upMTyEZGZ + Given you make sure the event z4PCgdaBXFh has no assigned user + And you land on the enrollment edit event page by having typed /#/enrollmentEventEdit?eventId=z4PCgdaBXFh&orgUnitId=g8upMTyEZGZ When you assign the user Geetha in the view mode Then the event has the user Geetha Alwan assigned When you remove the assigned user Then the event has no assignd user Scenario: You can assign a user to a event in the edit mode - Given you land on the enrollment edit event page by having typed /#/enrollmentEventEdit?eventId=TMw2x87EZmo&orgUnitId=g8upMTyEZGZ + Given you make sure the event TMw2x87EZmo has no assigned user + And you land on the enrollment edit event page by having typed /#/enrollmentEventEdit?eventId=TMw2x87EZmo&orgUnitId=g8upMTyEZGZ When you assign the user Tracker demo User in the edit mode Then the event has the user Tracker demo User assigned When you remove the assigned user diff --git a/cypress/support/commands.js b/cypress/support/commands.js index 898d81702c..3d390ae2d4 100644 --- a/cypress/support/commands.js +++ b/cypress/support/commands.js @@ -4,6 +4,14 @@ Cypress.Commands.add('buildApiUrl', (...urlParts) => .join('/'), ); +Cypress.Commands.add('importTracker', (importStrategy, payload) => + cy.buildApiUrl(`tracker?async=false&importStrategy=${importStrategy}`) + .then(url => cy.request('POST', url, payload)) + .then(({ body }) => { + expect(body.status).to.eq('OK'); + }), +); + Cypress.Commands.add( 'shouldIncludeClass', { prevSubject: true }, From 06963ad166dd19ed2cf89979efeed291d4670d1b Mon Sep 17 00:00:00 2001 From: Karoline Tufte Lien Date: Mon, 7 Sep 2026 19:11:25 +0200 Subject: [PATCH 5/7] chore: gate the sharing dialog search box before typing `you change the sharing settings` clicked "Share view" and typed into the dialog's search box with nothing in between. The box is disabled while the dialog loads the object's sharing state, and `cy.type()` does not wait for that to clear - the CI error carries no "Timed out retrying" prefix, so it threw immediately rather than after the command timeout. `cy.get` retried only until the input existed, which it does while still disabled. Gate it with a retrying `should('be.enabled')` and scope the lookup to the dialog, the way the newer WorkingListsSharing step already does - the bare `[placeholder="Search"]` would match any search box on the page. This is the failure seen on `cypress (2.42, 5)` and, on another branch, on `cypress (2.43, 5)` - same scenario, same error, different version, so a race rather than anything version specific. Verified against play stable-2-43-1: both sharing scenarios pass with the gate in place. Co-Authored-By: Claude Opus 5 (1M context) --- cypress/e2e/WorkingLists/sharedSteps.js | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/cypress/e2e/WorkingLists/sharedSteps.js b/cypress/e2e/WorkingLists/sharedSteps.js index 5a4f48c094..003aa086b5 100644 --- a/cypress/e2e/WorkingLists/sharedSteps.js +++ b/cypress/e2e/WorkingLists/sharedSteps.js @@ -235,7 +235,10 @@ Then(/^you can load the view with the name ?(.*)$/, (name) => { When('you change the sharing settings', () => { cy.get('[data-test="list-view-menu-button"]').click(); cy.contains('Share view').click(); - cy.get('[placeholder="Search"]').type('Boateng'); + cy.get('[data-test="sharing-dialog"]') + .find('[placeholder="Search"]') + .should('be.enabled') + .type('Boateng'); cy.contains('Kevin Boateng').click(); cy.contains('Choose a level').click(); cy.contains('View and edit').click({ force: true }); From b7694e7133a82b92e28407ceae14e781c7ce7c36 Mon Sep 17 00:00:00 2001 From: Karoline Tufte Lien Date: Wed, 9 Sep 2026 10:20:57 +0200 Subject: [PATCH 6/7] chore: address review on the de-flake seeding steps Inline the six tracker imports back to the per-callsite buildApiUrl form and drop cy.importTracker, so the specs keep a single house pattern instead of a half-migrated one. Removes the import-status assertion, which no other tracker import makes. Fetch the whole event body in the assignee guard rather than a field allowlist, matching the GET-then-UPDATE pattern in EnrollmentEditEventPageForm.js, and clear the assignment with an explicit assignedUser: null instead of omitting the field. Verified against play stable-2-43-1: the full-body UPDATE with assignedUser: null clears the assignment and leaves all 22 data values, occurredAt and status on TMw2x87EZmo unchanged. Co-Authored-By: Claude Opus 5 (1M context) --- cypress/e2e/RelatedStages/RelatedStages.js | 42 ++++++++++++------- .../WidgetAssignee/index.js | 14 +++---- cypress/support/commands.js | 8 ---- 3 files changed, 33 insertions(+), 31 deletions(-) diff --git a/cypress/e2e/RelatedStages/RelatedStages.js b/cypress/e2e/RelatedStages/RelatedStages.js index 6895893a3d..ac8b4e2b69 100644 --- a/cypress/e2e/RelatedStages/RelatedStages.js +++ b/cypress/e2e/RelatedStages/RelatedStages.js @@ -18,9 +18,11 @@ const unlinkEvent = eventId => return undefined; } - return cy.importTracker('DELETE', { - relationships: relationships.map(({ relationship }) => ({ relationship })), - }); + return cy + .buildApiUrl('tracker?async=false&importStrategy=DELETE') + .then(deleteUrl => cy.request('POST', deleteUrl, { + relationships: relationships.map(({ relationship }) => ({ relationship })), + })); }); const clearEnrolledEntity = () => { @@ -44,7 +46,9 @@ const clearEnrolledEntity = () => { return undefined; } - return cy.importTracker('DELETE', { trackedEntities }); + return cy + .buildApiUrl('tracker?async=false&importStrategy=DELETE') + .then(deleteUrl => cy.request('POST', deleteUrl, { trackedEntities })); }); }; @@ -68,16 +72,18 @@ Given(/^you make sure the enrollment (.+) has a Baby Postnatal event$/, (enrollm return undefined; } - return cy.importTracker('CREATE', { - events: [{ - program: body.program, - programStage: BABY_POSTNATAL_STAGE, - enrollment: enrollmentId, - orgUnit: body.orgUnit, - occurredAt: `${getCurrentYear()}-07-01`, - status: 'ACTIVE', - }], - }); + return cy + .buildApiUrl('tracker?async=false&importStrategy=CREATE') + .then(createUrl => cy.request('POST', createUrl, { + events: [{ + program: body.program, + programStage: BABY_POSTNATAL_STAGE, + enrollment: enrollmentId, + orgUnit: body.orgUnit, + occurredAt: `${getCurrentYear()}-07-01`, + status: 'ACTIVE', + }], + })); }); }); @@ -155,7 +161,9 @@ And('you delete the Birth event', () => { return undefined; } - return cy.importTracker('DELETE', { events: [eventToDelete] }); + return cy + .buildApiUrl('tracker?async=false&importStrategy=DELETE') + .then(eventUrl => cy.request('POST', eventUrl, { events: [eventToDelete] })); }) .then(() => cy.reload()); }); @@ -319,7 +327,9 @@ And(/^you delete the events of the enrollmentId (.*)$/, (enrollmentId) => { return undefined; } - return cy.importTracker('DELETE', { events }); + return cy + .buildApiUrl('tracker?async=false&importStrategy=DELETE') + .then(eventUrl => cy.request('POST', eventUrl, { events })); }) .then(() => cy.reload()); }); diff --git a/cypress/e2e/WidgetsForEnrollmentPages/WidgetAssignee/index.js b/cypress/e2e/WidgetsForEnrollmentPages/WidgetAssignee/index.js index fbfca19186..7df3fcb1ae 100644 --- a/cypress/e2e/WidgetsForEnrollmentPages/WidgetAssignee/index.js +++ b/cypress/e2e/WidgetsForEnrollmentPages/WidgetAssignee/index.js @@ -1,18 +1,18 @@ import { Given, When, Then } from '@badeball/cypress-cucumber-preprocessor'; -const EVENT_FIELDS = 'event,program,programStage,enrollment,orgUnit,occurredAt,scheduledAt,status,dataValues'; - Given(/^you make sure the event (.+) has no assigned user$/, (eventId) => { - cy.buildApiUrl('tracker', `events/${eventId}?fields=${EVENT_FIELDS},assignedUser`) + cy.buildApiUrl('tracker', `events/${eventId}`) .then(url => cy.request(url)) .then(({ body }) => { - const { assignedUser, ...event } = body; - - if (!assignedUser) { + if (!body.assignedUser) { return undefined; } - return cy.importTracker('UPDATE', { events: [event] }); + const eventToUpdate = { ...body, assignedUser: null }; + + return cy + .buildApiUrl('tracker?async=false&importStrategy=UPDATE') + .then(eventUrl => cy.request('POST', eventUrl, { events: [eventToUpdate] })); }); }); diff --git a/cypress/support/commands.js b/cypress/support/commands.js index 3d390ae2d4..898d81702c 100644 --- a/cypress/support/commands.js +++ b/cypress/support/commands.js @@ -4,14 +4,6 @@ Cypress.Commands.add('buildApiUrl', (...urlParts) => .join('/'), ); -Cypress.Commands.add('importTracker', (importStrategy, payload) => - cy.buildApiUrl(`tracker?async=false&importStrategy=${importStrategy}`) - .then(url => cy.request('POST', url, payload)) - .then(({ body }) => { - expect(body.status).to.eq('OK'); - }), -); - Cypress.Commands.add( 'shouldIncludeClass', { prevSubject: true }, From 610d2716d47bff3c0894452e3166eb5638ddb901 Mon Sep 17 00:00:00 2001 From: Karoline Tufte Lien Date: Wed, 9 Sep 2026 10:26:54 +0200 Subject: [PATCH 7/7] chore: drop the dead instances fallback from the cleanup step The tracker trackedEntities response has used the trackedEntities key since before the minimum supported version, so the instances branch this PR added was unreachable. Verified on play dev-2-41 (2.41.11), dev-2-42 (2.42.7) and stable-2-43-1: all three return trackedEntities and none return instances, against d2.config.js minDHIS2Version 2.41. Leaves the five pre-existing fallbacks in the other specs alone; those are a separate sweep. Co-Authored-By: Claude Opus 5 (1M context) --- cypress/e2e/RelatedStages/RelatedStages.js | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/cypress/e2e/RelatedStages/RelatedStages.js b/cypress/e2e/RelatedStages/RelatedStages.js index ac8b4e2b69..9957c0cdbe 100644 --- a/cypress/e2e/RelatedStages/RelatedStages.js +++ b/cypress/e2e/RelatedStages/RelatedStages.js @@ -39,8 +39,7 @@ const clearEnrolledEntity = () => { ) .then(url => cy.request(url)) .then(({ body }) => { - const apiTrackedEntities = body.trackedEntities || body.instances || []; - const trackedEntities = apiTrackedEntities.map(({ trackedEntity }) => ({ trackedEntity })); + const trackedEntities = (body.trackedEntities ?? []).map(({ trackedEntity }) => ({ trackedEntity })); if (!trackedEntities.length) { return undefined;