Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,9 @@ jobs:
secrets: inherit
e2e-tests:
uses: ./.github/workflows/e2e-tests.yaml
permissions:
contents: read
pull-requests: write
secrets: inherit
ci:
runs-on: ubuntu-slim
Expand Down
116 changes: 116 additions & 0 deletions .github/workflows/e2e-tests.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -73,3 +73,119 @@ jobs:
with:
name: cypress-screenshots-${{ matrix.os }}-${{ matrix.containers }}
path: cypress/screenshots
- name: Upload retry report
uses: actions/upload-artifact@v7
if: always() && hashFiles('cypress/reports/retries.json') != ''
with:
name: cypress-retries-${{ matrix.os }}-${{ matrix.containers }}
path: cypress/reports/retries.json

report-retries:
name: Report flaky test retries
needs: e2e-tests
if: always()
runs-on: ubuntu-slim
permissions:
contents: read
pull-requests: write
steps:
- name: Download retry reports
uses: actions/download-artifact@v8
with:
pattern: cypress-retries-*
path: retry-reports
continue-on-error: true
- name: Summarize retried tests
uses: actions/github-script@v9
with:
script: |
const fs = require('fs');
const path = require('path');

const root = 'retry-reports';
const retries = [];

if (fs.existsSync(root)) {
for (const shard of fs.readdirSync(root)) {
const reportFile = path.join(root, shard, 'retries.json');
if (!fs.existsSync(reportFile)) continue;
const entries = JSON.parse(fs.readFileSync(reportFile, 'utf8'));
for (const entry of entries) {
retries.push({ shard, ...entry });
}
}
}

if (retries.length === 0) {
await core.summary
.addHeading('Cypress retries', 3)
.addRaw('No specs needed a retry in this run.')
.write();
} else {
await core.summary
.addHeading('Cypress retries', 3)
.addTable([
[
{ data: 'Shard', header: true },
{ data: 'Spec', header: true },
{ data: 'Test', header: true },
{ data: 'Attempts', header: true }
],
...retries.map((retry) => [
retry.shard,
retry.spec,
retry.title,
String(retry.attempts)
])
])
.write();
}

// Best-effort PR comment: on forked PRs GITHUB_TOKEN is read-only,
// so a failure here must not fail the job. The step summary above
// is the primary, always-available output.
const pullRequest = context.payload.pull_request;
const isFork =
pullRequest &&
pullRequest.head.repo.full_name !== `${context.repo.owner}/${context.repo.repo}`;
if (!pullRequest || isFork) return;

const marker = '<!-- cypress-retry-report -->';
const body =
retries.length === 0

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: When a shard is killed before after:spec, or retry-artifact download fails, this branch treats the incomplete collection as a clean run and overwrites an existing PR comment with “No specs needed a retry.” Reconcile to zero retries only after confirming all expected shard reports were collected; otherwise leave the previous report unchanged and surface the collection failure.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At .github/workflows/e2e-tests.yaml, line 155:

<comment>When a shard is killed before `after:spec`, or retry-artifact download fails, this branch treats the incomplete collection as a clean run and overwrites an existing PR comment with “No specs needed a retry.” Reconcile to zero retries only after confirming all expected shard reports were collected; otherwise leave the previous report unchanged and surface the collection failure.</comment>

<file context>
@@ -152,26 +151,34 @@ jobs:
             const marker = '<!-- cypress-retry-report -->';
-            const body = `${marker}\n**${retries.length} Cypress test attempt(s) were retried in this run.** See the job summary for details.`;
+            const body =
+              retries.length === 0
+                ? `${marker}\n**No specs needed a retry in this run.**`
+                : `${marker}\n**${retries.length} Cypress test(s) were retried in this run.** See the job summary for details.`;
</file context>

? `${marker}\n**No specs needed a retry in this run.**`
: `${marker}\n**${retries.length} Cypress test(s) were retried in this run.** See the job summary for details.`;

try {
const comments = await github.paginate(
github.rest.issues.listComments,
{
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: pullRequest.number
}
);
const existingComment = comments.find((comment) =>
comment.body?.includes(marker)
);

if (existingComment) {
// Keep the marker comment in sync, including clearing it back
// to "no retries" so a fixed run doesn't leave a stale report.
await github.rest.issues.updateComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: existingComment.id,
body
});
} else if (retries.length > 0) {
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: pullRequest.number,
body
});
}
} catch (error) {
core.warning(`Could not post the retry-report comment: ${error.message}`);
}
3 changes: 3 additions & 0 deletions .github/workflows/tests.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -26,3 +26,6 @@ jobs:
run: uv run --no-project pytest --cov=chainlit/
- name: Run frontend tests
run: pnpm run test
- name: Run cypress support unit tests
if: matrix.python-version == '3.13'
run: pnpm test:unit
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ chainlit.md
cypress/screenshots
cypress/videos
cypress/downloads
cypress/reports

__pycache__

Expand Down
32 changes: 31 additions & 1 deletion cypress.config.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,26 @@
import { defineConfig } from 'cypress';
import cypressSplit from 'cypress-split';
import fkill from 'fkill';
import { mkdir, writeFile } from 'fs/promises';
import { dirname, join } from 'path';

import {
RetriedTest,
collectRetriedTests
} from './cypress/support/retryReport';
import { runChainlit } from './cypress/support/run';

export const CHAINLIT_APP_PORT = 8000;

// Per-shard record of tests that failed at least once but passed on retry,
// written after every spec so a killed job still leaves partial data behind.
const RETRY_REPORT_PATH = join(
process.cwd(),
'cypress',
'reports',
'retries.json'
);

async function killChainlit() {
await fkill(`:${CHAINLIT_APP_PORT}`, {
force: true,
Expand Down Expand Up @@ -41,12 +56,27 @@ export default defineConfig({
await killChainlit(); // Fallback to ensure no previous instance is running
await runChainlit(); // Start Chainlit before running tests as Cypress require

const retriedTests: RetriedTest[] = [];

on('before:spec', async (spec) => {
await killChainlit();
await runChainlit(spec);
});

on('after:spec', async () => {
on('after:spec', async (spec, results) => {
// `results` is undefined under `cypress open` (no run results are
// collected in interactive mode), so guard before reading it.
if (results) {
retriedTests.push(
...collectRetriedTests(spec.relative, results.tests)
);
}
await mkdir(dirname(RETRY_REPORT_PATH), { recursive: true });
await writeFile(
RETRY_REPORT_PATH,
JSON.stringify(retriedTests, null, 2)
);

await killChainlit();
});

Expand Down
63 changes: 63 additions & 0 deletions cypress/support/retryReport.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import assert from 'node:assert/strict';
import { test } from 'node:test';

import { collectRetriedTests } from './retryReport.ts';

const asTests = (tests: unknown) =>
tests as CypressCommandLine.RunResult['tests'];

test('ignores a test that passed on its first attempt', () => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: This regression test is never executed: nothing in any CI workflow or npm/pnpm script runs node --test cypress/support/retryReport.test.ts, and tsconfig.json excludes the file. The retry-detection logic therefore has no automated coverage in CI, so a future change to collectRetriedTests can silently regress. Wire the test into CI (e.g. a workflow step or a pnpm test script entry) so it actually runs on every PR.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At cypress/support/retryReport.test.ts, line 9:

<comment>This regression test is never executed: nothing in any CI workflow or npm/pnpm script runs `node --test cypress/support/retryReport.test.ts`, and tsconfig.json excludes the file. The retry-detection logic therefore has no automated coverage in CI, so a future change to collectRetriedTests can silently regress. Wire the test into CI (e.g. a workflow step or a `pnpm test` script entry) so it actually runs on every PR.</comment>

<file context>
@@ -0,0 +1,63 @@
+const asTests = (tests: unknown) =>
+  tests as CypressCommandLine.RunResult['tests'];
+
+test('ignores a test that passed on its first attempt', () => {
+  const tests = asTests([
+    {
</file context>

const tests = asTests([
{
title: ['suite', 'stable test'],
state: 'passed',
attempts: [{ state: 'passed' }]
}
]);

assert.deepEqual(
collectRetriedTests('cypress/e2e/example/spec.cy.ts', tests),
[]
);
});

test('reports a test that failed then passed as retried', () => {
const tests = asTests([
{
title: ['suite', 'flaky test'],
state: 'passed',
attempts: [{ state: 'failed' }, { state: 'passed' }]
}
]);

assert.deepEqual(
collectRetriedTests('cypress/e2e/example/spec.cy.ts', tests),
[
{
spec: 'cypress/e2e/example/spec.cy.ts',
title: 'suite > flaky test',
attempts: 2
}
]
);
});

test('excludes a test that failed every attempt', () => {
const tests = asTests([
{
title: ['suite', 'broken test'],
state: 'failed',
attempts: [
{ state: 'failed' },
{ state: 'failed' },
{ state: 'failed' },
{ state: 'failed' }
]
}
]);

assert.deepEqual(
collectRetriedTests('cypress/e2e/example/spec.cy.ts', tests),
[]
);
});
28 changes: 28 additions & 0 deletions cypress/support/retryReport.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
export interface RetriedTest {
spec: string;
title: string;
attempts: number;
}

/**
* A test counts as retried when at least one attempt failed but the overall
* test still passed - i.e. Cypress' `retries` config absorbed the failure.
* Tests that exhaust every retry and still fail are already visible via the
* job's exit status, so they are excluded here.
*/
export function collectRetriedTests(
specRelative: string,
tests: CypressCommandLine.RunResult['tests']
): RetriedTest[] {
return tests
.filter(
(test) =>
test.state === 'passed' &&
test.attempts.some((attempt) => attempt.state === 'failed')
)
.map((test) => ({
spec: specRelative,
title: test.title.join(' > '),
attempts: test.attempts.length
}));
}
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,8 @@
"check-overrides": "node scripts/check-pnpm-overrides.mjs",
"test": "pnpm run --recursive test",
"test:e2e": "cypress run",
"test:e2e:interactive": "cypress open"
"test:e2e:interactive": "cypress open",
"test:unit": "node --test cypress/support/*.test.ts"
},
"pnpm": {
"overrides": {
Expand Down
4 changes: 3 additions & 1 deletion tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@
"lib": ["ESNext", "dom"],
"types": ["cypress", "cypress-plugin-steps", "node"],
"baseUrl": ".",
"esModuleInterop": true
"esModuleInterop": true,
"allowImportingTsExtensions": true,
"noEmit": true
},
"include": ["cypress/**/*.ts", "cypress.config.ts"]
}