diff --git a/.github/workflows/api-testing.yml b/.github/workflows/api-testing.yml index 62a2425b6c..27694dcfd1 100644 --- a/.github/workflows/api-testing.yml +++ b/.github/workflows/api-testing.yml @@ -654,6 +654,8 @@ jobs: # 2. Nothing API-relevant changed → nothing ran → allow. if [ "${{ needs.check_changes.outputs.has_changes }}" != "true" ]; then echo "✅ No API-relevant changes — suite not required." + echo "::notice title=API tests skipped::No API-relevant changes — suite did not run." + echo "### ⏭️ API suite skipped — no API-relevant changes in this PR, so no tests ran." >> "$GITHUB_STEP_SUMMARY" exit 0 fi diff --git a/.github/workflows/playwright.yml b/.github/workflows/playwright.yml index e474142ff3..3bd77dea95 100644 --- a/.github/workflows/playwright.yml +++ b/.github/workflows/playwright.yml @@ -1168,6 +1168,8 @@ jobs: # 2. Nothing Playwright-relevant changed → nothing ran → allow. if [ "${{ needs.check_changes.outputs.has_changes }}" != "true" ]; then echo "✅ No Playwright-relevant changes — suite not required." + echo "::notice title=Playwright tests skipped::No Playwright-relevant changes — suite did not run." + echo "### ⏭️ Playwright suite skipped — no Playwright-relevant changes in this PR, so no tests ran." >> "$GITHUB_STEP_SUMMARY" exit 0 fi diff --git a/tests/ui-testing/pages/logsPages/logsPage.js b/tests/ui-testing/pages/logsPages/logsPage.js index f721098477..aef687db06 100644 --- a/tests/ui-testing/pages/logsPages/logsPage.js +++ b/tests/ui-testing/pages/logsPages/logsPage.js @@ -3447,13 +3447,7 @@ export class LogsPage { // Create lives in the Saved views submenu, which only renders while open. const createSavedViewBtn = this.page.locator(this.menuCreateSavedViewBtn); if (!(await createSavedViewBtn.isVisible({ timeout: 500 }).catch(() => false))) { - const submenuTrigger = this.page.locator(this.menuListSavedViewsBtn); - if (!(await submenuTrigger.isVisible().catch(() => false))) { - await this.page.locator(this.utilitiesMenuButton).click({ force: true }); - await submenuTrigger.waitFor({ state: 'visible', timeout: 5000 }); - } - await submenuTrigger.click({ force: true }); - await createSavedViewBtn.waitFor({ state: 'visible', timeout: 5000 }); + await this.openSavedViewsSubmenu(this.menuCreateSavedViewBtn); } await createSavedViewBtn.click(); await this.page.locator(this.savedViewDialog).waitFor({ state: 'visible', timeout: 10000 }); @@ -9035,12 +9029,29 @@ export class LogsPage { await listMenuItem.waitFor({ state: 'hidden', timeout: 2000 }).catch(() => {}); } if (!opened) throw new Error('Could not open saved views menu after 3 attempts'); - // force: true — ODropdown portal transiently detaches items during initial render - await listMenuItem.click({ force: true }); - await this.page.locator(this.savedViewSearchInput).waitFor({ state: 'visible', timeout: 10000 }); + await this.openSavedViewsSubmenu(this.savedViewSearchInput); testLogger.info('Clicked saved views dropdown arrow'); } + /** + * Open the Saved views submenu of the utilities menu and wait for `targetSelector` inside it. + * @param {string} targetSelector - element that only renders while the submenu is open + */ + async openSavedViewsSubmenu(targetSelector) { + const trigger = this.page.locator(this.menuListSavedViewsBtn); + const target = this.page.locator(targetSelector); + for (let attempt = 0; attempt < 3; attempt++) { + if (!(await trigger.isVisible().catch(() => false))) { + await this.page.locator(this.utilitiesMenuButton).click({ force: true }); + await trigger.waitFor({ state: 'visible', timeout: 5000 }).catch(() => {}); + } + // No force: a click during the menu's open animation lands outside it and dismisses the whole menu. + await trigger.click({ timeout: 5000 }).catch(() => {}); + if (await target.waitFor({ state: 'visible', timeout: 3000 }).then(() => true).catch(() => false)) return; + } + await target.waitFor({ state: 'visible', timeout: 5000 }); + } + /** * Expand the saved views dropdown and wait for search input * Tries arrow click first, then main button if search input doesn't appear diff --git a/web/src/ts/interfaces/oncall.ts b/web/src/ts/interfaces/oncall.ts index b2b2d6b0a1..338b3400a1 100644 --- a/web/src/ts/interfaces/oncall.ts +++ b/web/src/ts/interfaces/oncall.ts @@ -1005,7 +1005,7 @@ export interface EscalationProgress { next_targets: string[]; next_at?: number | null; exhausted: boolean; - /** Set when the ladder is not climbing: acknowledged, snoozed, resolved. */ + /** Set when the ladder is not climbing: acknowledged, snoozed, resolved, unrouted, team deleted, or exhausted (check `exhausted`). */ stopped_because?: string | null; /** While the first page is held for AI triage, when the hold ends (micros). */ held_until_micros?: number | null; diff --git a/web/src/utils/oncall.spec.ts b/web/src/utils/oncall.spec.ts index 69f2483ff6..cece07dd39 100644 --- a/web/src/utils/oncall.spec.ts +++ b/web/src/utils/oncall.spec.ts @@ -45,6 +45,7 @@ import { isShiftRuleValid, isSnoozed, isDeletedTeam, + ladderStopped, pagePhrase, reachedStepOf, pageState, @@ -516,6 +517,36 @@ describe("isDeletedTeam", () => { }); }); +describe("ladderStopped", () => { + const progress = (exhausted: boolean, stopped_because: string | null) => + ({ fired: [], next_targets: [], next_at: null, exhausted, stopped_because }) as any; + + it.each([ + [ + "an exhausted ladder", + progress(true, "the ladder is exhausted — nobody acknowledged"), + false, + false, + ], + [ + "a ladder stopped for another reason", + progress(false, "the owning team was deleted"), + false, + true, + ], + ["a climbing ladder", progress(false, null), false, false], + [ + "a deleted team, even with an exhausted ladder", + progress(true, "the ladder is exhausted — nobody acknowledged"), + true, + true, + ], + ["a deleted team with no progress loaded", undefined, true, true], + ])("%s", (_name, ladder, teamDeleted, expected) => { + expect(ladderStopped(ladder, teamDeleted)).toBe(expected); + }); +}); + describe("reachedStepOf", () => { it("names the 1-based step a recorded rung delay matches, or nothing", () => { const steps = [{ after_micros: 900 }, { after_micros: 0 }, { after_micros: 300 }]; diff --git a/web/src/utils/oncall.ts b/web/src/utils/oncall.ts index d5ba06804f..91ad57a9d7 100644 --- a/web/src/utils/oncall.ts +++ b/web/src/utils/oncall.ts @@ -302,12 +302,12 @@ export function isDeletedTeam( return !!teamId && knownTeamIds !== null && !knownTeamIds.includes(teamId); } -/** A ringing page whose ladder stopped, or whose team was deleted, pages nobody; the list's count and phrase both ask this. [pure] */ +/** A ringing page whose ladder stopped, or whose team was deleted, pages nobody; a ladder that ran out also sets `stopped_because` but is still paging. [pure] */ export function ladderStopped( progress: EscalationProgress | null | undefined, teamDeleted: boolean, ): boolean { - return teamDeleted || !!progress?.stopped_because; + return teamDeleted || (!!progress?.stopped_because && !progress?.exhausted); } /** What is happening to a page, as one phrase: the Pages list, Recent pages and the page header all say this. */ diff --git a/web/src/views/OnCall/OnCallResponses.spec.ts b/web/src/views/OnCall/OnCallResponses.spec.ts index 9afe86640b..4364587819 100644 --- a/web/src/views/OnCall/OnCallResponses.spec.ts +++ b/web/src/views/OnCall/OnCallResponses.spec.ts @@ -565,6 +565,9 @@ describe("OnCallResponses", () => { expect(sections(wrapper)).toEqual({ ringing: 1 }); expect(wrapper.find('[data-test^="oncall-row-ack-"]').exists()).toBe(true); + const header = wrapper.find("[data-test='oncall-section-header-ringing']").text(); + expect(header).toContain("nobody has acknowledged — these are still paging"); + expect(header).not.toContain("not being paged"); }); /// A priority with no rungs configured (P4, by design) never starts a