@@ -125,12 +125,20 @@ jobs:
125125 echo "synapse_pat_available=true" >> $GITHUB_OUTPUT;
126126 fi
127127
128+ - name : Download Failed Tests from Previous Attempt
129+ if : ${{ github.run_attempt > 1 && (contains(fromJSON('["3.9"]'), matrix.python) || contains(fromJSON('["3.13"]'), matrix.python)) && steps.secret-check.outputs.secrets_available == 'true'}}
130+ uses : actions/download-artifact@v4
131+ with :
132+ name : failed-tests-${{ matrix.os }}-${{ matrix.python }}
133+ continue-on-error : true
134+
128135 # run integration tests iff the decryption keys for the test configuration are available.
129136 # they will not be available in pull requests from forks.
130137 # run integration tests on the oldest and newest supported versions of python.
131138 # we don't run on the entire matrix to avoid a 3xN set of concurrent tests against
132139 # the target server where N is the number of supported python versions.
133140 - name : run-integration-tests
141+ id : integration_tests
134142 shell : bash
135143
136144 # keep versions consistent with the first and last from the strategy matrix
@@ -204,11 +212,96 @@ jobs:
204212 echo "Running integration tests for Max Python version (3.13) - ignoring synchronous tests"
205213 fi
206214
207- # use loadscope to avoid issues running tests concurrently that share scoped fixtures
208- pytest -sv --reruns 3 --cov-append --cov=. --cov-report xml tests/integration -n 8 $IGNORE_FLAGS --dist loadscope
215+ # Check if we should run only failed tests from previous attempt
216+ if [[ -f failed_tests.txt && -s failed_tests.txt ]]; then
217+ echo "::notice::Retry attempt ${{ github.run_attempt }} detected - running only previously failed tests"
218+ cat failed_tests.txt
219+
220+ # Run only the failed tests
221+ pytest -sv --reruns 3 --cov-append --cov=. --cov-report xml \
222+ --junit-xml=test-results.xml \
223+ -n 8 --dist loadscope \
224+ $(cat failed_tests.txt | tr '\n' ' ')
225+ else
226+ echo "::notice::First attempt or no previous failures - running full integration test suite"
227+
228+ # use loadscope to avoid issues running tests concurrently that share scoped fixtures
229+ pytest -sv --reruns 3 --cov-append --cov=. --cov-report xml \
230+ --junit-xml=test-results.xml \
231+ tests/integration -n 8 $IGNORE_FLAGS --dist loadscope
232+ fi
209233
210234 # Execute the CLI tests in a non-dist way because they were causing some test instability when being run concurrently
211235 pytest -sv --reruns 3 --cov-append --cov=. --cov-report xml tests/integration/synapseclient/test_command_line_client.py
236+
237+ - name : Extract Failed Tests
238+ if : always() && steps.integration_tests.outcome == 'failure'
239+ shell : bash
240+ run : |
241+ python -c "
242+ import xml.etree.ElementTree as ET
243+ import os
244+ tree = ET.parse('test-results.xml')
245+ root = tree.getroot()
246+ failed = []
247+ for testcase in root.iter('testcase'):
248+ if testcase.find('failure') is not None or testcase.find('error') is not None:
249+ classname = testcase.get('classname')
250+ name = testcase.get('name')
251+ file_attr = testcase.get('file')
252+
253+ # Use the file attribute if available, otherwise convert classname
254+ if file_attr:
255+ # file attribute is already in the correct format
256+ test_path = f'{file_attr}::{classname.split(\".\")[-1]}::{name}'
257+ else:
258+ # Convert classname from dot notation to file path
259+ # e.g., 'tests.integration.foo.test_bar.TestClass' -> 'tests/integration/foo/test_bar.py::TestClass'
260+ parts = classname.split('.')
261+ # Find the test file (usually starts with 'test_')
262+ module_parts = []
263+ class_parts = []
264+ found_test_file = False
265+ for part in parts:
266+ if not found_test_file:
267+ module_parts.append(part)
268+ if part.startswith('test_'):
269+ found_test_file = True
270+ else:
271+ class_parts.append(part)
272+
273+ file_path = '/'.join(module_parts) + '.py'
274+ if class_parts:
275+ test_path = f'{file_path}::{\"::\".join(class_parts)}::{name}'
276+ else:
277+ test_path = f'{file_path}::{name}'
278+
279+ failed.append(test_path)
280+
281+ with open('failed_tests.txt', 'w') as f:
282+ f.write('\n'.join(failed))
283+ print(f'Found {len(failed)} failed tests')
284+ for test in failed:
285+ print(f' - {test}')
286+ print(f'Current attempt: ${{ github.run_attempt }}')
287+ "
288+
289+ - name : Upload Failed Tests for Next Attempt
290+ if : always() && steps.integration_tests.outcome == 'failure' && github.run_attempt < 3
291+ uses : actions/upload-artifact@v4
292+ with :
293+ name : failed-tests-${{ matrix.os }}-${{ matrix.python }}
294+ path : failed_tests.txt
295+ retention-days : 2
296+ overwrite : true
297+
298+ - name : Fail job if integration tests failed after all retries
299+ if : always() && steps.integration_tests.outcome == 'failure'
300+ shell : bash
301+ run : |
302+ echo "::error::Integration tests failed after ${{ github.run_attempt }} attempt(s)"
303+ exit 1
304+
212305 - name : Upload coverage report
213306 id : upload_coverage_report
214307 uses : actions/upload-artifact@v4
0 commit comments