Skip to content

Commit b477919

Browse files
tiago80673Tomás Teixeira
and
Tomás Teixeira
committed
feat: allow to rerun only failed tests (ui) (cypress-io#4886)
Co-authored-by: Tomás Teixeira <tomas.teixeira@tecnico.ulisboa.pt>
1 parent 3706062 commit b477919

File tree

17 files changed

+311
-4
lines changed

17 files changed

+311
-4
lines changed

packages/app/cypress/e2e/sidebar_navigation.cy.ts

+4
Original file line numberDiff line numberDiff line change
@@ -119,9 +119,11 @@ describe('Sidebar Navigation', { viewportWidth: 1280 }, () => {
119119
cy.get('li p').contains('Re-run tests').should('be.visible')
120120
cy.get('li p').contains('Stop tests').should('be.visible')
121121
cy.get('li p').contains('Toggle specs list').should('be.visible')
122+
cy.get('li p').contains('Re-run failed tests').should('be.visible')
122123
cy.get('li span').contains('r')
123124
cy.get('li span').contains('s')
124125
cy.get('li span').contains('f')
126+
cy.get('li span').contains('t')
125127

126128
// cy.percySnapshot() // TODO: restore when Percy CSS is fixed. See https://github.com/cypress-io/cypress/issues/23435
127129
cy.get('[aria-label="Close"]').click()
@@ -216,9 +218,11 @@ describe('Sidebar Navigation', { viewportWidth: 1280 }, () => {
216218
cy.get('li p').contains('Re-run tests').should('be.visible')
217219
cy.get('li p').contains('Stop tests').should('be.visible')
218220
cy.get('li p').contains('Toggle specs list').should('be.visible')
221+
cy.get('li p').contains('Re-run failed tests').should('be.visible')
219222
cy.get('li span').contains('r')
220223
cy.get('li span').contains('s')
221224
cy.get('li span').contains('f')
225+
cy.get('li span').contains('t')
222226
cy.get('[aria-label="Close"]').click()
223227
cy.findByText('Keyboard shortcuts').should('not.exist')
224228
})

packages/app/src/navigation/KeyboardBindingsModal.vue

+4
Original file line numberDiff line numberDiff line change
@@ -56,5 +56,9 @@ const keyBindings = [
5656
key: ['f'],
5757
description: t('sidebar.keyboardShortcuts.toggle'),
5858
},
59+
{
60+
key: ['t'],
61+
description: t('sidebar.keyboardShortcuts.failed'),
62+
},
5963
]
6064
</script>

packages/app/src/runner/event-manager.ts

+26
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,15 @@ export class EventManager {
9999
return this.rerunSpec()
100100
}
101101

102+
const rerunF = () => {
103+
if (!this) {
104+
// if the tests have been reloaded then there is nothing to rerun
105+
return
106+
}
107+
108+
return this.rerunFailedSpec()
109+
}
110+
102111
const connectionInfo: AddGlobalListenerOptions = {
103112
element: options.element,
104113
randomString: options.randomString,
@@ -214,6 +223,8 @@ export class EventManager {
214223

215224
this.reporterBus.on('runner:restart', rerun)
216225

226+
this.reporterBus.on('runner:restart-failed', rerunF)
227+
217228
const sendEventIfSnapshotProps = (testId, logId, event) => {
218229
if (!Cypress) return
219230

@@ -857,6 +868,21 @@ export class EventManager {
857868
this.localBus.emit('restart')
858869
}
859870

871+
async rerunFailedSpec () {
872+
if (!this || !Cypress) {
873+
// if the tests have been reloaded then there is nothing to rerun
874+
return
875+
}
876+
877+
await this.resetReporter()
878+
879+
// this probably isn't 100% necessary since Cypress will fall out of scope
880+
// but we want to be aggressive here and force GC early and often
881+
Cypress.removeAllListeners()
882+
883+
this.localBus.emit('restart-failed')
884+
}
885+
860886
_interceptStudio (displayProps) {
861887
if (this.studioStore.isActive) {
862888
displayProps.hookId = this.studioStore.hookId

packages/app/src/runner/index.ts

+4-1
Original file line numberDiff line numberDiff line change
@@ -411,7 +411,7 @@ async function initialize () {
411411
* 5. Setup the spec. This involves a few things, see the `runSpecCT` function's
412412
* description for more information.
413413
*/
414-
async function executeSpec (spec: SpecFile, isRerun: boolean = false) {
414+
async function executeSpec (spec: SpecFile, isRerun: boolean = false, skipPassed: boolean = false) {
415415
await teardownSpec(isRerun)
416416

417417
const mobxRunnerStore = getMobxRunnerStore()
@@ -425,6 +425,9 @@ async function executeSpec (spec: SpecFile, isRerun: boolean = false) {
425425
// TODO: UNIFY-1318 - figure out how to manage window.config.
426426
const config = getRunnerConfigFromWindow()
427427

428+
// this lets Cypress driver skip passed tests
429+
spec.skipPassed = skipPassed
430+
428431
// this is how the Cypress driver knows which spec to run.
429432
config.spec = setSpecForDriver(spec)
430433

packages/app/src/runner/useEventManager.ts

+11-2
Original file line numberDiff line numberDiff line change
@@ -13,13 +13,13 @@ export function useEventManager () {
1313
const studioStore = useStudioStore()
1414
const router = useRouter()
1515

16-
function runSpec (isRerun: boolean = false) {
16+
function runSpec (isRerun: boolean = false, skipPassed: boolean = false) {
1717
if (!specStore.activeSpec) {
1818
throw Error(`Cannot run spec when specStore.active spec is null or undefined!`)
1919
}
2020

2121
autStore.setScriptError(null)
22-
UnifiedRunnerAPI.executeSpec(specStore.activeSpec, isRerun)
22+
UnifiedRunnerAPI.executeSpec(specStore.activeSpec, isRerun, skipPassed)
2323
}
2424

2525
function initializeRunnerLifecycleEvents () {
@@ -33,6 +33,15 @@ export function useEventManager () {
3333
}
3434
})
3535

36+
eventManager.on('restart-failed', () => {
37+
// If we get the event to restart but have already navigated away from the runner, don't execute the spec
38+
if (specStore.activeSpec) {
39+
const isRerun = true
40+
41+
runSpec(isRerun, true)
42+
}
43+
})
44+
3645
eventManager.on('script:error', (err) => {
3746
autStore.setScriptError(err)
3847
})
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
/// <reference types="cypress" />
2+
3+
import { flattenTestsFromSuites, loadPassedTests, savePassedTests, setSkipOnPassedTests } from '../../../src/cypress/runner'
4+
5+
// Sample data for testing
6+
const sampleSuite = {
7+
suites: [
8+
{
9+
tests: [
10+
{ id: 1, body: 'test1', state: 'passed' },
11+
{ id: 2, body: 'test2', state: 'failed' },
12+
],
13+
suites: [
14+
{
15+
tests: [
16+
{ id: 3, body: 'test3', state: 'passed' },
17+
{ id: 4, body: 'test4', state: 'failed' },
18+
],
19+
},
20+
],
21+
},
22+
],
23+
}
24+
25+
describe('Test Suite Processing Functions', () => {
26+
beforeEach(() => {
27+
cy.window().then((win) => {
28+
win.passedTestsInfo = {}
29+
})
30+
})
31+
32+
it('should flatten all tests from nested suites', () => {
33+
const result = flattenTestsFromSuites(sampleSuite)
34+
35+
expect(result).to.have.length(4)
36+
expect(result.map((test) => test.id)).to.include.members([1, 2, 3, 4])
37+
})
38+
39+
it('should return passed tests from window object', () => {
40+
const testLocation = 'testPath'
41+
42+
cy.window().then((win) => {
43+
win.passedTestsInfo[testLocation] = [{ id: 1, body: 'test1' }]
44+
45+
const result = loadPassedTests(testLocation)
46+
47+
expect(result).to.have.length(1)
48+
expect(result[0].id).to.equal(1)
49+
})
50+
})
51+
52+
it('should return an empty array if no passed tests are found', () => {
53+
const testLocation = 'testPath'
54+
55+
cy.window().then((win) => {
56+
win.passedTestsInfo = {}
57+
58+
const result = loadPassedTests(testLocation)
59+
60+
expect(result).to.be.an('array').that.is.empty
61+
})
62+
})
63+
64+
it('should save passed tests and remove failed ones', () => {
65+
const savedInfo = [{ id: 1, body: 'test1' }]
66+
const testLocation = 'testPath'
67+
68+
savePassedTests(sampleSuite, savedInfo, testLocation)
69+
70+
cy.window().then((win) => {
71+
const info = win.passedTestsInfo[testLocation]
72+
73+
expect(info).to.have.length(2)
74+
expect(info.map((test) => test.id)).to.include.members([1, 3])
75+
})
76+
})
77+
78+
it('should set tests to pending if they were previously passed', () => {
79+
const savedInfo = [{ id: 1, body: 'test1' }, { id: 3, body: 'test3' }]
80+
81+
setSkipOnPassedTests(sampleSuite, savedInfo)
82+
83+
const allTests = flattenTestsFromSuites(sampleSuite)
84+
const pendingTests = allTests.filter((test) => test.pending === 'true')
85+
86+
expect(pendingTests).to.have.length(2)
87+
expect(pendingTests.map((test) => test.id)).to.include.members([1, 3])
88+
})
89+
})

packages/driver/src/cypress/runner.ts

+81
Original file line numberDiff line numberDiff line change
@@ -361,6 +361,76 @@ const isLastSuite = (suite, tests) => {
361361
.value() === suite
362362
}
363363

364+
export function flattenTestsFromSuites (data) {
365+
let allTests = []
366+
367+
function extractTests (obj) {
368+
if (obj.tests) {
369+
allTests = allTests.concat(obj.tests)
370+
}
371+
372+
if (obj.suites) {
373+
obj.suites.forEach((suite) => extractTests(suite))
374+
}
375+
}
376+
377+
extractTests(data)
378+
379+
return allTests
380+
}
381+
382+
// retrieve data from the last run to enable skipping
383+
export function loadPassedTests (testLocation) {
384+
if (!window.passedTestsInfo) {
385+
window.passedTestsInfo = {}
386+
}
387+
388+
//TODO should be from a file and not from window
389+
return window.passedTestsInfo[testLocation] ? window.passedTestsInfo[testLocation] : []
390+
//return $utils.loadLastRunInfoFromFile(specsPath)
391+
}
392+
393+
// this allows saving the info from the last run to enable skipping
394+
export function savePassedTests (suite, savedInfo, testLocation) {
395+
//if the tests finished running the info is suite.suites[0]
396+
//if the tests were stopped during execution the info is still on suite
397+
let currentTests = flattenTestsFromSuites(suite)
398+
399+
function saveTest (test) {
400+
if (!test) {
401+
return
402+
}
403+
404+
if (test.state === 'passed') {
405+
if (!savedInfo.some((savedTest) => savedTest.id === test.id)) {
406+
savedInfo.push({ 'id': test.id, 'body': test.body })
407+
}
408+
} else if (test.state === 'failed') {
409+
if (savedInfo.some((savedTest) => savedTest.id === test.id)) {
410+
savedInfo = savedInfo.filter((savedTest) => savedTest.id !== test.id)
411+
}
412+
}
413+
}
414+
415+
currentTests.forEach(saveTest.bind(this))
416+
417+
//TODO should be from a file and not from window
418+
window.passedTestsInfo[testLocation] = savedInfo
419+
//$utils.saveLastRunInfoToFile(specsPath, savedInfo)
420+
}
421+
422+
export function setSkipOnPassedTests (suite, savedInfo) {
423+
let currentTests = flattenTestsFromSuites(suite)
424+
425+
function skipTest (test) {
426+
if (savedInfo.some((savedTest) => savedTest.body === test.body)) {
427+
test.pending = 'true'
428+
}
429+
}
430+
431+
currentTests.forEach(skipTest.bind(this))
432+
}
433+
364434
// we are the last test that will run in the suite
365435
// if we're the last test in the tests array or
366436
// if we failed from a hook and that hook was 'before'
@@ -1217,9 +1287,15 @@ export default {
12171287
let _uncaughtFn: (() => never) | null = null
12181288
let _resumedAtTestIndex: number | null = null
12191289
let _skipCollectingLogs = true
1290+
let _passedTests = []
1291+
let _shouldSkipPassedTests = false
12201292
const _runner = mocha.getRunner()
12211293

1294+
_passedTests = loadPassedTests(Cypress.spec.absolute)
12221295
_runner.suite = mocha.getRootSuite()
1296+
if (Cypress.spec.skipPassed) {
1297+
_shouldSkipPassedTests = true
1298+
}
12231299

12241300
function isNotAlreadyRunTest (test) {
12251301
return _resumedAtTestIndex == null || getTestIndexFromId(test.id) >= _resumedAtTestIndex
@@ -1583,6 +1659,10 @@ export default {
15831659
mocha.createRootTest('An uncaught error was detected outside of a test', _uncaughtFn)
15841660
}
15851661

1662+
if (_shouldSkipPassedTests) {
1663+
setSkipOnPassedTests(_runner.suite, _passedTests)
1664+
}
1665+
15861666
return normalizeAll(
15871667
_runner.suite,
15881668
tests,
@@ -1905,6 +1985,7 @@ export default {
19051985
},
19061986

19071987
stop () {
1988+
savePassedTests(_runner.suite, _passedTests, Cypress.spec.absolute)
19081989
if (_runner.stopped) {
19091990
return
19101991
}

packages/driver/src/cypress/utils.ts

+41
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ import capitalize from 'underscore.string/capitalize'
33
import methods from 'methods'
44
import dayjs from 'dayjs'
55
import $ from 'jquery'
6+
import * as fs from 'fs'
7+
import * as path from 'path'
68

79
import $dom from '../dom'
810
import $jquery from '../dom/jquery'
@@ -419,4 +421,43 @@ export default {
419421
// @ts-ignore
420422
return ret && _.isObject(ret) && 'then' in ret && _.isFunction(ret.then) && 'catch' in ret && _.isFunction(ret.catch)
421423
},
424+
425+
checkFileExists (file) {
426+
return fs.promises.access(file, fs.constants.F_OK)
427+
.then(() => true)
428+
.catch(() => false)
429+
},
430+
431+
loadLastRunInfoFromFile (pathF) {
432+
const directory = path.dirname('/')
433+
const fileBaseName = path.basename(pathF, path.extname(pathF))
434+
const filePath = path.join(directory, fileBaseName+'-passed.txt')
435+
436+
try {
437+
if (fs.existsSync(filePath)) {
438+
fs.promises.readFile(filePath, 'utf8').then((data) => {
439+
return JSON.parse(data)
440+
})
441+
}
442+
443+
return []
444+
} catch (error) {
445+
// do smth
446+
return []
447+
}
448+
},
449+
450+
async saveLastRunInfoToFile (pathF, testInfo) {
451+
const directory = path.dirname('/')
452+
const fileBaseName = path.basename(pathF, path.extname(pathF))
453+
const filePath = path.join(directory, fileBaseName+'-passed')
454+
455+
try {
456+
const data = JSON.stringify(testInfo, null, 2)
457+
458+
fs.promises.writeFile(filePath, data, { encoding: 'utf8', flag: 'w' })
459+
} catch (error) {
460+
// do smth
461+
}
462+
},
422463
}
Loading

0 commit comments

Comments
 (0)