diff --git a/cli/CHANGELOG.md b/cli/CHANGELOG.md index 91fe2121667..f0e9c69627f 100644 --- a/cli/CHANGELOG.md +++ b/cli/CHANGELOG.md @@ -1,10 +1,11 @@ ## 15.6.0 -_Released 10/20/2025 (PENDING)_ +_Released 11/4/2025 (PENDING)_ **Features:** + - Added a 'Self-healed' badge to the Command Log when `cy.prompt()` steps automatically recover after the element they need is not found in the cache. Addressed in [#32802](https://github.com/cypress-io/cypress/pull/32802). - `cy.prompt()` will now show a warning in the `Get code` modal when there are unsaved changes in `Studio` that will be lost if the user saves the generated code. Addressed in [#32741](https://github.com/cypress-io/cypress/pull/32741). **Bugfixes:** diff --git a/packages/reporter/cypress/e2e/commands.cy.ts b/packages/reporter/cypress/e2e/commands.cy.ts index 069eee2ac19..a214a172442 100644 --- a/packages/reporter/cypress/e2e/commands.cy.ts +++ b/packages/reporter/cypress/e2e/commands.cy.ts @@ -1142,4 +1142,150 @@ describe('commands', { viewportHeight: 1000 }, () => { cy.contains('GET /api/data/1').should('be.visible') }) }) + + context('self-healed badge', () => { + it('renders the self-healed badge when the command is self-healed and move to top level when is closed', () => { + const nestedGroupId = addCommand(runner, { + name: 'session', + defaultCollapsedState: 'open', + state: 'passed', + type: 'child', + }) + + addCommand(runner, { + name: 'get', + message: 'do something', + state: 'passed', + groupLevel: 1, + group: nestedGroupId, + }) + + const nestedSessionGroupId = addCommand(runner, { + name: 'session', + defaultCollapsedState: 'open', + displayName: 'validate', + type: 'child', + groupLevel: 2, + group: nestedGroupId, + renderProps: { + selfHealed: true, + }, + }) + + addCommand(runner, { + name: 'log', + message: 'inside of group', + state: 'passed', + group: nestedSessionGroupId, + }) + + cy.get('[data-cy="self-healed-badge-command"]').should('exist') + cy.get('[data-cy="self-healed-badge-test"]').should('exist') + + cy.percySnapshot('initial state') + + cy.get('.command-message').eq(10).within(() => { + cy.get('[data-cy="self-healed-badge-command"]').should('not.exist') + }) + + cy.get('.command-message').eq(12).within(() => { + cy.get('[data-cy="self-healed-badge-command"]').should('exist') + }) + + cy.get('.command-expander').eq(1).click() + + cy.percySnapshot('after clicking command expander') + + cy.get('.command-message').eq(10).within(() => { + cy.get('[data-cy="self-healed-badge-command"]').should('exist') + }) + + cy.get('.collapsible-header-inner').eq(2).click({ force: true }) + + cy.percySnapshot('after clicking collapsible header') + + cy.get('[data-cy="self-healed-badge-command"]').should('not.exist') + cy.get('[data-cy="self-healed-badge-test"]').should('exist') + + cy.get('.collapsible-header-inner').first().click() + + cy.get('[data-cy="self-healed-badge-command"]').should('not.exist') + cy.get('[data-cy="self-healed-badge-test"]').should('exist') + + cy.percySnapshot() + }) + + it('renders the self-healed badge when the command is self-healed and long text and move to top level when is closed', () => { + const nestedGroupId = addCommand(runner, { + name: 'session', + defaultCollapsedState: 'open', + state: 'passed', + type: 'child', + message: 'with long text to show wrapping works as expected and move to top level when is closed and is self-healed and is long text to show wrapping works as expected', + }) + + addCommand(runner, { + name: 'get', + message: 'do something', + state: 'passed', + groupLevel: 1, + group: nestedGroupId, + }) + + const nestedSessionGroupId = addCommand(runner, { + name: 'session', + defaultCollapsedState: 'open', + displayName: 'validate', + type: 'child', + groupLevel: 2, + group: nestedGroupId, + message: 'with long text to show wrapping works as expected and move to top level when is closed and is self-healed and is long text to show wrapping works as expected', + renderProps: { + selfHealed: true, + }, + }) + + addCommand(runner, { + name: 'log', + message: 'inside of group', + state: 'passed', + group: nestedSessionGroupId, + }) + + cy.get('[data-cy="self-healed-badge-command"]').should('exist') + cy.get('[data-cy="self-healed-badge-test"]').should('exist') + + cy.percySnapshot('initial state') + + cy.get('.command-message').eq(10).within(() => { + cy.get('[data-cy="self-healed-badge-command"]').should('not.exist') + }) + + cy.get('.command-message').eq(12).within(() => { + cy.get('[data-cy="self-healed-badge-command"]').should('exist') + }) + + cy.get('.command-expander').eq(1).click() + + cy.percySnapshot('after clicking command expander') + + cy.get('.command-message').eq(10).within(() => { + cy.get('[data-cy="self-healed-badge-command"]').should('exist') + }) + + cy.get('.collapsible-header-inner').eq(2).click({ force: true }) + + cy.percySnapshot('after clicking collapsible header') + + cy.get('[data-cy="self-healed-badge-command"]').should('not.exist') + cy.get('[data-cy="self-healed-badge-test"]').should('exist') + + cy.get('.collapsible-header-inner').first().click() + + cy.get('[data-cy="self-healed-badge-command"]').should('not.exist') + cy.get('[data-cy="self-healed-badge-test"]').should('exist') + + cy.percySnapshot() + }) + }) }) diff --git a/packages/reporter/cypress/e2e/unit/test_model.cy.ts b/packages/reporter/cypress/e2e/unit/test_model.cy.ts index f520c0b2d52..8423f4f1305 100644 --- a/packages/reporter/cypress/e2e/unit/test_model.cy.ts +++ b/packages/reporter/cypress/e2e/unit/test_model.cy.ts @@ -368,4 +368,40 @@ describe('Test model', () => { expect(test.isOpen).eq(true) }) }) + + context('#isSelfHealed', () => { + it('false by default', () => { + const test = createTest() + + expect(test.isSelfHealed).to.be.false + }) + + it('true when there is a self-healed command', () => { + const test = createTest() + + test.addLog(createCommand({ renderProps: { selfHealed: true } })) + expect(test.isSelfHealed).to.be.true + }) + + it('true when there is a self-healed command in an attempt', () => { + const test = createTest({ + currentRetry: 2, + prevAttempts: [ + { id: 'r3', currentRetry: 0, state: 'failed', hooks: [] }, + { id: 'r3', currentRetry: 1, state: 'failed', hooks: [] }, + ], + }) + + // Add a regular command to the first attempt + test.addLog(createCommand({ testCurrentRetry: 0, renderProps: { selfHealed: false } })) + + // Add a self-healed command to the second attempt + test.addLog(createCommand({ testCurrentRetry: 1, renderProps: { selfHealed: true } })) + + // Add a regular command to the current (third) attempt + test.addLog(createCommand({ testCurrentRetry: 2, renderProps: { selfHealed: false } })) + + expect(test.isSelfHealed).to.be.true + }) + }) }) diff --git a/packages/reporter/src/commands/command-model.ts b/packages/reporter/src/commands/command-model.ts index 132780db386..2b58e45ca0e 100644 --- a/packages/reporter/src/commands/command-model.ts +++ b/packages/reporter/src/commands/command-model.ts @@ -21,6 +21,7 @@ export interface RenderProps { }> status?: InterceptStatuses | XHRStatuses wentToOrigin?: boolean + selfHealed?: boolean } export interface CommandProps extends InstrumentProps { @@ -163,6 +164,7 @@ export default class Command extends Instrument { hasChildren: computed, showError: computed, setGroup: action, + isSelfHealed: computed, }) if (props.err) { @@ -278,4 +280,8 @@ export default class Command extends Instrument { _isPending () { return this.state === 'pending' } + + get isSelfHealed () { + return (!!this.renderProps.selfHealed || (this.hasChildren && !this.isOpen && this.children.some((child) => child.isSelfHealed))) + } } diff --git a/packages/reporter/src/commands/command.tsx b/packages/reporter/src/commands/command.tsx index 59c461cee6f..da2ef284c04 100644 --- a/packages/reporter/src/commands/command.tsx +++ b/packages/reporter/src/commands/command.tsx @@ -25,6 +25,7 @@ import HiddenIcon from '@packages/frontend-shared/src/assets/icons/general-eye-c import PinIcon from '@packages/frontend-shared/src/assets/icons/object-pin_x16.svg' import RunningIcon from '@packages/frontend-shared/src/assets/icons/status-running_x16.svg' import { IconTechnologyAngleBrackets } from '@cypress-design/react-icon' +import { SelfHealedBadge } from '../lib/selfHealedBadge' const displayName = (model: CommandModel) => model.displayName || model.name const nameClassName = (name: string) => name.replace(/(\s+)/g, '-') @@ -279,6 +280,9 @@ const Message: React.FC = observer(({ model }: MessageProps) => ( className='command-message-text' dangerouslySetInnerHTML={{ __html: formattedMessage(model.displayMessage, model.name) }} />} + {model.isSelfHealed && ( + + )} )) diff --git a/packages/reporter/src/commands/commands.scss b/packages/reporter/src/commands/commands.scss index 5353c97fa20..99607cf18b3 100644 --- a/packages/reporter/src/commands/commands.scss +++ b/packages/reporter/src/commands/commands.scss @@ -421,6 +421,7 @@ white-space: initial; word-wrap: inherit; width: 100%; + margin-right: 8px; } // Styles for Uncaught Exception diff --git a/packages/reporter/src/lib/selfHealedBadge.scss b/packages/reporter/src/lib/selfHealedBadge.scss new file mode 100644 index 00000000000..f19849e3024 --- /dev/null +++ b/packages/reporter/src/lib/selfHealedBadge.scss @@ -0,0 +1,33 @@ +.command-self-healed-badge { + background-color: $gray-1000; + border-radius: 4px; + display: inline-flex; + height: 20px; + border: 1px solid $gray-900; + gap: 4px; + padding: 0 4px; + color: $jade-300; + font-family: $font-system; + font-size: 14px; + font-weight: 400; + margin-right: 4px; + align-items: center; + justify-content: center; + white-space: nowrap; + text-transform: none; + vertical-align: middle; + + .command-info:hover & { + border-color: $gray-800; + } + +} + +.command-self-healed-badge-command { + height: 16px; + font-size: 12px; +} + +.command-self-healed-badge-test { + margin-left: 8px; +} \ No newline at end of file diff --git a/packages/reporter/src/lib/selfHealedBadge.tsx b/packages/reporter/src/lib/selfHealedBadge.tsx new file mode 100644 index 00000000000..890a90636d6 --- /dev/null +++ b/packages/reporter/src/lib/selfHealedBadge.tsx @@ -0,0 +1,14 @@ +import React from 'react' +import { IconGeneralSparkleSingleSmall } from '@cypress-design/react-icon' +import cs from 'classnames' + +export const SelfHealedBadge = ({ source }: { source: 'command' | 'test' }) => { + return ( +
+ + + Self-healed + +
+ ) +} diff --git a/packages/reporter/src/main-runner.scss b/packages/reporter/src/main-runner.scss index 91761e2bd1f..dd8f97b305b 100644 --- a/packages/reporter/src/main-runner.scss +++ b/packages/reporter/src/main-runner.scss @@ -6,6 +6,7 @@ @import 'lib/switch'; @import 'lib/tag'; @import 'lib/tooltip'; +@import 'lib/selfHealedBadge'; @import '@reach/dialog/styles.css'; // import all other scss files in src except if they are in lib // or their file name is `selector-playground` or `main` diff --git a/packages/reporter/src/main.scss b/packages/reporter/src/main.scss index d4aea12f9af..ddc28fafd7b 100644 --- a/packages/reporter/src/main.scss +++ b/packages/reporter/src/main.scss @@ -12,6 +12,7 @@ @import 'lib/switch'; @import 'lib/tag'; @import 'lib/tooltip'; +@import 'lib/selfHealedBadge'; @import '@reach/dialog/styles.css'; // import all other scss files in src except if they are in lib // or their file name is `selector-playground` or `main` diff --git a/packages/reporter/src/test/test-model.ts b/packages/reporter/src/test/test-model.ts index 424ca2b373b..89db85d288c 100644 --- a/packages/reporter/src/test/test-model.ts +++ b/packages/reporter/src/test/test-model.ts @@ -76,6 +76,7 @@ export default class Test extends Runnable { hasRetried: computed, isActive: computed, currentRetry: computed, + isSelfHealed: computed, start: action, update: action, setIsOpen: action, @@ -257,4 +258,12 @@ export default class Test extends Runnable { return null } + + get isSelfHealed () { + // Compute self-healed status from the commands in all attempts + // This ensures the badge is shown correctly even across retries + return _.some(this.attempts, (attempt: Attempt) => { + return _.some(attempt.commands, (command) => command.isSelfHealed) + }) + } } diff --git a/packages/reporter/src/test/test.tsx b/packages/reporter/src/test/test.tsx index 116df5db0b8..b63e41630d2 100644 --- a/packages/reporter/src/test/test.tsx +++ b/packages/reporter/src/test/test.tsx @@ -10,6 +10,7 @@ import Attempts from '../attempts/attempts' import StateIcon from '../lib/state-icon' import { LaunchStudioIcon } from '../components/LaunchStudioIcon' import { useScrollIntoView } from '../lib/useScrollIntoView' +import { SelfHealedBadge } from '../lib/selfHealedBadge' interface TestProps { events?: Events @@ -45,6 +46,9 @@ const Test: React.FC = observer(({ model, events: eventsProps = event {model.title} {model.state} + {model.isSelfHealed && ( + + )} {_controls()} )