Skip to content
Merged
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
2 changes: 2 additions & 0 deletions .changeset/honest-lemons-travel.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
---
---
2 changes: 1 addition & 1 deletion .claude/skills/verify-clerk-expo/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -319,7 +319,7 @@ For a remote device, `down` ends the runner job and waits for it to finish, and
- `src/host.ts`, `src/fixture.ts`, `src/native-build.ts`, and `src/freshness.ts` are this repository's own: the build of the test app, the native build that CI keeps between runs, the Metro ports, and the check that Metro serves current JS. `specs/app.ts` names the test app and its entry for a dev client, and `specs/native.ts` holds the per-platform locators for the native views and the locators of the home's links. Both are this repository's own too.
- `npm test --prefix integration/expo-native` runs the CLI's unit tests, with no network, key, or device. `npm run typecheck --prefix integration/expo-native` runs `tsc`. The `Expo Native Runner Tests` job in `.github/workflows/ci.yml` runs both on Linux when a pull request changes the package, the test app, or a package the test app links.
- `run --github-report` hands the results of the run to `@e2e-dev/github` as one report. The reporter writes the report to the job summary. With a `GITHUB_TOKEN` that may write pull request comments, it also posts one comment on the pull request and updates that comment on later runs. The reporter never changes the exit code, and nothing is reported for a run with a tainted file.
- `.github/workflows/verify-e2e.yml`, the `Expo E2E` workflow, runs `up --backend local`, `run --all --retries 1 --max-failures 5 --github-report`, and `down` on a runner for each platform, with a device and a Clerk application for each. It runs on the Version packages pull request (`changeset-release/main`), so each release is tested before it publishes, and the `Expo E2E` check fails when a spec fails. On other pull requests the device tests skip and the `Expo E2E` check passes. Start it by hand with `gh workflow run verify-e2e.yml --ref <branch>`. A test that fails and then passes on its one retry is `flaky` and does not fail the job. The runners are Blacksmith's `blacksmith-6vcpu-macos-26` and `blacksmith-8vcpu-ubuntu-2204`, and the repository variables `VERIFY_CI_RUNNER_IOS` and `VERIFY_CI_RUNNER_ANDROID` name other labels. The Platform API key comes from the `MOBILE_VERIFICATION_PLATFORM_API_KEY` repository secret. The workflow sets `VERIFY_LOCAL_BUILD=standalone` and `VERIFY_NATIVE_CACHE` ([freshness.md](references/freshness.md)). It uploads each run's `run.json`, app log, driver logs, video, screenshots, and e2e's `report.json`, `junit.xml`, summary, and failure pages for three days, and only when no secret is found in the run. `bin/configure-ios-simulators.sh` speeds up animations and turns off autocorrect and the keyboard tutorials on each simulator in `CLERK_TEST_DEVICES`, and this workflow and `.github/workflows/verify-remote.yml` call it.
- `.github/workflows/verify-e2e.yml`, the `Expo E2E` workflow, runs `up --backend local`, `run --all --retries 1 --max-failures 5 --github-report`, and `down` on a runner for each platform, with a device and a Clerk application for each. It runs on the Version packages pull request (`changeset-release/main`), so each release is tested before it publishes, and the `Expo E2E` check fails when a spec fails. On other pull requests the device tests skip and the `Expo E2E` check passes. Start it by hand with `gh workflow run verify-e2e.yml --ref <branch>`. A test that fails and then passes on its one retry is `flaky` and does not fail the job. The runners are Blacksmith's `blacksmith-6vcpu-macos-26` and `blacksmith-8vcpu-ubuntu-2204`, and the repository variables `VERIFY_CI_RUNNER_IOS` and `VERIFY_CI_RUNNER_ANDROID` name other labels. The Platform API key comes from the `MOBILE_VERIFICATION_PLATFORM_API_KEY` repository secret. The workflow sets `VERIFY_LOCAL_BUILD=standalone` and `VERIFY_NATIVE_CACHE` ([freshness.md](references/freshness.md)). It uploads each run's `run.json`, app log, driver logs, video, screenshots, and e2e's `report.json`, `junit.xml`, summary, and failure pages for three days, and only when no secret is found in the run. After a run on the release pull request, the `Post the flaky tests to Slack` job posts the tests that passed only on their retry to #team-mobile-alerts. `bin/configure-ios-simulators.sh` speeds up animations and turns off autocorrect and the keyboard tutorials on each simulator in `CLERK_TEST_DEVICES`, and this workflow and `.github/workflows/verify-remote.yml` call it.
- The `Expo` workflow, `.github/workflows/expo-native-build.yml`, runs nothing on a device. It builds the test app as a Release app on Expo SDK 55 and 58 for each platform, and runs the Android unit tests of `@clerk/expo-biometrics`.
- `.github/workflows/verify-attach.yml` publishes the evidence that `attach` hands to a session's runner, with `scripts/verify-attach.mjs`. It reads the manifest that `src/core/remote/handoff.ts` of `@clerk/internal-mobile-e2e` writes. It and the package's `src/core/publish.ts` each replace the block the other wrote, each applies the same rules to the comments and to the start of the line, and each lays the files out in the same tables. The script is checked out alone, so it imports nothing from the package. So a change to the manifest, the comments, those rules, or the tables is a change in both places. `scripts/verify-attach.test.mjs` has its tests.
- A remote session runs in `.github/workflows/verify-remote.yml`. `src/platform/session-device.ts` holds the steps that the session runs on its device, and `src/core/remote/` of `@clerk/internal-mobile-e2e` is the code on both ends of it. The runner installs the package with `npm ci`. A session keeps the core it installed when it started, so after a change to the commit pin of `@clerk/internal-mobile-e2e`, commit, push, `down`, then `up`.
Expand Down
2 changes: 2 additions & 0 deletions .github/workflows/actions.lock
Original file line number Diff line number Diff line change
Expand Up @@ -98,10 +98,12 @@ workflows:
'.github/workflows/verify-e2e.yml':
- 'actions/cache@v4.3.0'
- 'actions/checkout@v4.4.0'
- 'actions/download-artifact@v4.3.0'
- 'actions/setup-java@v4.9.1'
- 'actions/setup-node@v4.4.0'
- 'actions/upload-artifact@v4.6.2'
- 'pnpm/action-setup@v4.3.0'
- 'slackapi/slack-github-action@v1.27.1'
'.github/workflows/verify-remote.yml':
- 'actions/checkout@v4.4.0'
- 'actions/setup-java@v4.9.1'
Expand Down
4 changes: 2 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -379,9 +379,9 @@ jobs:
env:
NODE_VERSION: ${{ matrix.node-version }}

- name: Run verify-attach script tests
- name: Run verify script tests
if: matrix.node-version == '24.15.0'
run: pnpm vitest run --root scripts verify-attach
run: pnpm vitest run --root scripts verify-attach verify-flaky

- name: Upload Turbo Summary
uses: actions/upload-artifact@v4.6.2
Expand Down
68 changes: 68 additions & 0 deletions .github/workflows/verify-e2e.yml
Original file line number Diff line number Diff line change
Expand Up @@ -221,6 +221,74 @@ jobs:
retention-days: 3
if-no-files-found: warn

- name: Record the flaky tests
if: ${{ !cancelled() && steps.evidence.outputs.upload == 'true' }}
continue-on-error: true
run: node scripts/verify-flaky.mjs record "$PACKAGE/.verify/runs" "$RUNNER_TEMP/verify-flaky.json"
Comment thread
coderabbitai[bot] marked this conversation as resolved.

- name: Keep the flaky tests for the Slack post
if: ${{ !cancelled() && steps.evidence.outputs.upload == 'true' }}
continue-on-error: true
uses: actions/upload-artifact@v4.6.2
with:
name: verify-flaky-${{ matrix.platform }}
path: ${{ runner.temp }}/verify-flaky.json
retention-days: 1
overwrite: true
if-no-files-found: ignore

report-flaky:
name: Post the flaky tests to Slack
needs: e2e-tests
if: ${{ !cancelled() && needs.e2e-tests.result != 'skipped' && github.head_ref == 'changeset-release/main' }}
runs-on: ubuntu-latest
timeout-minutes: 5
permissions:
contents: read
steps:
- name: Checkout repo
uses: actions/checkout@v4.4.0
with:
persist-credentials: false
sparse-checkout: scripts/verify-flaky.mjs
sparse-checkout-cone-mode: false

- uses: actions/setup-node@v4.4.0
with:
node-version: 24.15.0

- name: Download the flaky tests of both platforms
continue-on-error: true
uses: actions/download-artifact@v4.3.0
with:
pattern: verify-flaky-*
path: ${{ runner.temp }}/flaky

- name: Generate notification payload
id: notification
run: |
mkdir -p "$RUNNER_TEMP/flaky"
payload="$(node scripts/verify-flaky.mjs payload "$RUNNER_TEMP/flaky")"
if [ -z "$payload" ]; then
echo "No flaky tests in this run."
exit 0
fi
echo "$payload"
{
echo 'payload<<__NOTIFY_EOF__'
echo "$payload"
echo '__NOTIFY_EOF__'
} >> "$GITHUB_OUTPUT"

- name: Send the flaky tests to Slack
if: ${{ steps.notification.outputs.payload != '' }}
uses: slackapi/slack-github-action@v1.27.1
with:
payload: ${{ steps.notification.outputs.payload }}
env:
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_MOBILE_TEAM_ALERTS_WEBHOOK_URL }}
SLACK_WEBHOOK_TYPE: INCOMING_WEBHOOK

verify-e2e:
name: Expo E2E
needs: e2e-tests
Expand Down
133 changes: 133 additions & 0 deletions scripts/verify-flaky.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';

const ERROR_CHARS = 300;

function flakesFromReport(report) {
return report.run.results
.filter(result => result.status === 'flaky')
.map(result => ({
platform: result.platform,
file: result.file,
title: result.titlePath.join(' › '),
error: result.attempts.find(attempt => attempt.status === 'failed')?.error?.message ?? '',
}));
}

function normalizeError(message) {
const normalized = message
.replace(/[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/g, '<email>')
.replace(/\b(user|sess|ins|app|org)_[A-Za-z0-9]+/g, '$1_<id>')
.replace(/after launch [0-9a-f]+/g, 'after launch <id>')
.replace(/; passes: \d+;.*$/s, '')
.replace(/\s+/g, ' ')
.trim();
return normalized.length > ERROR_CHARS ? `${normalized.slice(0, ERROR_CHARS)}…` : normalized;
}

const slackText = text => text.replaceAll('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;');

const mostCommon = counts => [...counts.entries()].sort((a, b) => b[1] - a[1])[0][0];

function flakyPayload(records) {
const tests = new Map();
for (const record of records) {
for (const flake of record.flakes) {
const key = `${flake.file}\n${flake.title}`;
const test = tests.get(key) ?? { file: flake.file, title: flake.title, platforms: new Set(), errors: new Map() };
const error = normalizeError(flake.error);
test.platforms.add(flake.platform);
test.errors.set(error, (test.errors.get(error) ?? 0) + 1);
tests.set(key, test);
}
}
if (tests.size === 0) {
return null;
}

const markdown = text => ({ type: 'section', text: { type: 'mrkdwn', text } });
const runUrl = records.find(record => record.runUrl)?.runUrl;
const count = tests.size === 1 ? '1 test' : `${tests.size} tests`;
const header = [
'*:warning: Expo E2E flaky tests*',
'*Pull request:* Version packages',
`*Flaky:* ${count} passed only on a retry`,
...(runUrl ? [`*Run:* <${runUrl}|View logs>`] : []),
];
const sorted = [...tests.values()].sort((a, b) => a.file.localeCompare(b.file) || a.title.localeCompare(b.title));
const blocks = [markdown(header.join('\n'))];
for (const test of sorted) {
const lines = [
`\`${test.file.replace(/^specs\/features\//, '')}\` ${slackText(test.title)}`,
`*Platforms:* ${[...test.platforms].sort().join(', ')}`,
];
const error = mostCommon(test.errors);
if (error !== '') {
lines.push(`*First attempt failed:* ${slackText(error)}`);
}
blocks.push(markdown(lines.join('\n')));
}
return { blocks, unfurl_links: false, unfurl_media: false };
}

const readJson = file => JSON.parse(fs.readFileSync(file, 'utf8'));

const byDirectory = (a, b) => path.dirname(a).localeCompare(path.dirname(b), 'en', { numeric: true });

function recordStep(runsDir, outFile, env) {
const reports = fs.existsSync(runsDir)
? fs
.globSync('*/e2e*/report.json', { cwd: runsDir })
.sort(byDirectory)
.map(file => readJson(path.join(runsDir, file)))
: [];
const runUrl =
env.GITHUB_SERVER_URL && env.GITHUB_REPOSITORY && env.GITHUB_RUN_ID
? `${env.GITHUB_SERVER_URL}/${env.GITHUB_REPOSITORY}/actions/runs/${env.GITHUB_RUN_ID}`
: null;
const record = {
runUrl,
commit: env.GITHUB_SHA || (reports[0]?.run.vcs?.commit ?? null),
flakes: reports.flatMap(flakesFromReport),
};
fs.writeFileSync(outFile, `${JSON.stringify(record, null, 2)}\n`);
return record;
}

function readRecords(dir) {
return fs
.globSync('**/*.json', { cwd: dir })
.sort(byDirectory)
.map(file => {
const record = readJson(path.join(dir, file));
if (!Array.isArray(record?.flakes)) {
throw new Error(`${file} is not a record of flaky tests`);
}
return record;
});
}

function main(argv, env, log = console.log) {
const [step, ...rest] = argv;
if (step === 'record' && rest.length === 2) {
const record = recordStep(rest[0], rest[1], env);
log(`Recorded ${record.flakes.length} flaky tests in ${rest[1]}.`);
return 0;
}
if (step === 'payload' && rest.length === 1) {
const payload = flakyPayload(readRecords(rest[0]));
if (payload !== null) {
log(JSON.stringify(payload));
}
return 0;
}
log('usage: verify-flaky.mjs record <runsDir> <outFile> | payload <dir>');
return 2;
}

export { flakesFromReport, flakyPayload, main, normalizeError };

if (process.argv[1] !== undefined && fs.realpathSync(process.argv[1]) === fileURLToPath(import.meta.url)) {
process.exit(main(process.argv.slice(2), process.env));
}
Loading
Loading