Skip to content

Commit 489b168

Browse files
Soft manual steps (allowed to fail) (#15)
* Soft manual steps (allowed to fail) * Soft manual steps (allowed to fail) - Readme --------- Co-authored-by: Max Soloviov <max.soloviov@keenethics.com>
1 parent cf0acc2 commit 489b168

File tree

9 files changed

+92
-25
lines changed

9 files changed

+92
-25
lines changed

README.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,20 @@ test('example with manual step', async ({ page, manualStep }) => {
3030

3131
When `manualStep` is called the test pauses and the Cyborg Test UI window appears. Use the `✅ Step passed` or `❌ Step failed` buttons to resume the test. Failing a step throws an error so your CI can detect it.
3232

33+
## Soft Fail for Manual Steps
34+
35+
You can use `manualStep.soft` to mark a manual step as a soft fail. If a soft manual step fails, the test will continue running, and the failure will be annotated as a soft fail (warning) in the report.
36+
37+
**Usage:**
38+
39+
```ts
40+
await manualStep('This is a hard manual step'); // Test fails if this step fails
41+
await manualStep.soft('This is a soft manual step'); // Test continues if this step fails, and a warning is shown
42+
```
43+
44+
- Soft fails are shown as warnings in the UI and annotated in the test report.
45+
- Use soft fails for non-critical manual verifications where you want to highlight issues but not fail the entire test.
46+
3347
## Analytics Configuration
3448

3549
The package includes Google Analytics integration that is enabled by default. The following data is collected:

package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@cyborgtests/test",
3-
"version": "0.0.6",
3+
"version": "0.0.7",
44
"description": "Powerfull extension for Playwright, that allows you to include manual verification steps in your automated test flow",
55
"main": "dist/index.umd.js",
66
"module": "dist/index.es.js",

src/test.ts

Lines changed: 35 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import {
22
Browser,
33
BrowserContext,
44
Page,
5+
expect,
56
test as pwTest,
67
} from "@playwright/test";
78
import { chromium } from "playwright";
@@ -59,21 +60,23 @@ const test = pwTest.extend<{
5960
server.kill();
6061
},
6162
manualStep: async ({ testControl, page, browser, context }, use) => {
62-
const manualStep = async (stepName: string) =>
63+
const manualStep = async (stepName: string, params: { isSoft?: boolean } = {}) =>
6364
await test.step(
64-
stepName,
65+
`✋ [MANUAL] ${stepName}`,
6566
async () => {
6667
await testControl.page.evaluate((_testName) => {
6768
(window as any)?.testUtils?.setTestName(_testName);
6869
}, test.info().title);
6970

7071
// Write current step name
71-
await testControl.page.evaluate((_stepName) => {
72-
(window as any)?.testUtils?.addStep(_stepName);
73-
}, stepName);
72+
await testControl.page.evaluate(
73+
({ stepName, params }) => {
74+
(window as any).testUtils?.addStep(stepName, params);
75+
},
76+
{ stepName, params }
77+
);
7478

7579
// Pause for manual step
76-
7780
await testControl.page.pause();
7881

7982
// If last step failed, throw error
@@ -85,7 +88,32 @@ const test = pwTest.extend<{
8588
return false;
8689
});
8790
if (hasFailed) {
88-
throw new TestFailedError(stepName as string);
91+
const reason = await testControl.page.evaluate(() => {
92+
const reason = (window as any).testUtils?.failedReason || '';
93+
delete (window as any).testUtils.failedReason;
94+
return reason;
95+
});
96+
const errorMessage = `${stepName}${reason ? ` - ${reason}` : ''}`;
97+
throw new TestFailedError(errorMessage);
98+
}
99+
},
100+
{ box: true }
101+
);
102+
manualStep.soft = async (stepName: string) =>
103+
await test.step(
104+
`✋ [MANUAL][SOFT] ${stepName}`,
105+
async () => {
106+
try {
107+
await manualStep(stepName, { isSoft: true });
108+
} catch (err) {
109+
test.info().annotations.push({
110+
type: 'softFail',
111+
description: `Soft fail in manual step: ${(err as Error).message}`,
112+
});
113+
// This will mark the step as failed, but not fail the test
114+
await expect.soft(false, `Soft fail in manual step: ${(err as Error).message}`).toBeTruthy();
115+
// Optionally log
116+
console.warn(`Soft fail in manual step: ${stepName}`, err);
89117
}
90118
},
91119
{ box: true }

src/ui/components/StepsList.tsx

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,33 @@
11
import { useTestStore } from '../store/TestStore';
22
import React, { Fragment } from 'react';
33

4+
const getClassName = (status: string) => {
5+
if (status === 'pass') {
6+
return 'step-pass';
7+
} else if (status === 'fail') {
8+
return 'step-fail';
9+
} else if (status === 'warning') {
10+
return 'step-warning';
11+
} else {
12+
return '';
13+
}
14+
};
15+
416
export default function StepsList() {
517
const { state } = useTestStore();
18+
619
return (
720
<Fragment>
821
<h4>Steps:</h4>
922
<ol id="stepsList">
1023
{state.steps.map((step, idx) => (
1124
<li
1225
key={idx}
13-
className={
14-
step.status === 'pass'
15-
? 'step-pass'
16-
: step.status === 'fail'
17-
? 'step-fail'
18-
: ''
19-
}
26+
className={getClassName(step.status)}
2027
>
2128
{step.status === 'pass' && '✅ '}
2229
{step.status === 'fail' && '❌ '}
30+
{step.status === 'warning' && '⚠️ '}
2331
{step.text}
2432
{step.status === 'fail' && step.reason ? ` - ${step.reason}` : ''}
2533
</li>

src/ui/components/TestControlPanel.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,8 @@ export default function TestControlPanel() {
1313
setTestName: (name: string) => {
1414
dispatch({ type: 'SET_TEST_NAME', payload: name });
1515
},
16-
addStep: (step: string) => {
17-
dispatch({ type: 'ADD_STEP', payload: step });
16+
addStep: (step: string, params: { isSoft?: boolean } = {}) => {
17+
dispatch({ type: 'ADD_STEP', payload: { step, isSoft: params.isSoft || false } });
1818
},
1919
};
2020
// Cleanup

src/ui/components/TestControls.tsx

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,18 +3,24 @@ import { useTestStore } from '../store/TestStore';
33
import { trackEvent } from '../../utils/analytics';
44

55
export default function TestControls() {
6+
const { state } = useTestStore();
7+
68
const { dispatch } = useTestStore();
79
const [failureReason, setFailureReason] = useState('');
810

911
const trackButtonClick = (buttonName: string) => {
1012
trackEvent(`app_${buttonName}_click`);
1113
};
1214

15+
const controlButtonsAreDisabled = state.steps[state.steps.length - 1]?.status !== 'pending';
16+
1317
return (
1418
<Fragment>
1519
<button
16-
className="btn-success"
20+
className="btn btn-success"
21+
disabled={controlButtonsAreDisabled}
1722
onClick={() => {
23+
if (controlButtonsAreDisabled) return;
1824
dispatch({ type: 'PASS_STEP' });
1925
(window as any).playwright?.resume();
2026
trackButtonClick('pass_step');
@@ -31,10 +37,13 @@ export default function TestControls() {
3137
/>
3238

3339
<button
34-
className="btn-danger"
40+
className="btn btn-danger"
41+
disabled={controlButtonsAreDisabled}
3542
onClick={() => {
43+
if (controlButtonsAreDisabled) return;
3644
dispatch({ type: 'FAIL_STEP', payload: failureReason || 'No failure reason provided' });
3745
(window as any).testUtils.hasFailed = true;
46+
(window as any).testUtils.failedReason = failureReason;
3847
(window as any).playwright?.resume();
3948
setFailureReason('');
4049
trackButtonClick('fail_step');

src/ui/store/TestStore.tsx

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,9 @@ import React, { createContext, useContext, ReactNode, Dispatch } from 'react';
33
// Types
44
export type Step = {
55
text: string;
6-
status: 'pending' | 'pass' | 'fail';
6+
status: 'pending' | 'pass' | 'fail' | 'warning';
77
reason?: string;
8+
isSoft?: boolean;
89
};
910

1011
interface State {
@@ -20,7 +21,7 @@ const initialState: State = {
2021
// Actions
2122
export type Action =
2223
| { type: 'SET_TEST_NAME'; payload: string }
23-
| { type: 'ADD_STEP'; payload: string }
24+
| { type: 'ADD_STEP'; payload: { step: string; isSoft?: boolean } }
2425
| { type: 'PASS_STEP' }
2526
| { type: 'FAIL_STEP'; payload: string };
2627

@@ -31,7 +32,7 @@ function reducer(state: State, action: Action): State {
3132
case 'ADD_STEP':
3233
return {
3334
...state,
34-
steps: [...state.steps, { text: action.payload, status: 'pending' }],
35+
steps: [...state.steps, { text: action.payload.step, status: 'pending', isSoft: action.payload.isSoft }],
3536
};
3637
case 'PASS_STEP': {
3738
const steps = [...state.steps];
@@ -48,7 +49,7 @@ function reducer(state: State, action: Action): State {
4849
if (steps.length > 0) {
4950
steps[steps.length - 1] = {
5051
...steps[steps.length - 1],
51-
status: 'fail',
52+
status: steps[steps.length - 1].isSoft ? 'warning' : 'fail',
5253
reason: action.payload,
5354
};
5455
}

src/ui/styles/TestControlPanel.css

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,10 @@ button {
4242
font-weight: 600;
4343
transition: background-color 0.2s;
4444
}
45+
button:disabled {
46+
opacity: 0.7;
47+
cursor: progress;
48+
}
4549
.btn-success {
4650
background-color: #2ecc71;
4751
color: white;
@@ -78,4 +82,7 @@ input:focus {
7882
}
7983
.step-fail {
8084
color: #e74c3c;
85+
}
86+
.step-warning {
87+
color: #f1c40f;
8188
}

0 commit comments

Comments
 (0)