Skip to content

Commit b0c1041

Browse files
ci(repo): scope integration test inputs to packages the e2e apps use
Generate each integration suite's turbo inputs from the packages the e2e harness and its apps depend on, so unrelated changes no longer invalidate every suite. Force full integration runs in the merge queue and nightly.
1 parent dc4b2e2 commit b0c1041

6 files changed

Lines changed: 375 additions & 27 deletions

File tree

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
---
2+
---

‎.github/workflows/ci.yml‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,9 @@ jobs:
8585
- name: Check Dashboard links
8686
run: pnpm lint:dashboard-links
8787

88+
- name: Verify integration test inputs are generated
89+
run: node scripts/integration-test-inputs.mjs --check
90+
8891
- name: Verify localizations are generated
8992
run: |
9093
pnpm --filter @clerk/localizations generate
@@ -700,12 +703,16 @@ jobs:
700703
id: integration-tests
701704
timeout-minutes: 25
702705
run: |
706+
if [ "$EVENT" = "merge_group" ]; then
707+
TURBO_ARGS="$TURBO_ARGS --force"
708+
fi
703709
if [ "${{ matrix.test-name }}" = "electron" ]; then
704710
xvfb-run -a pnpm turbo test:integration:${{ matrix.test-name }} $TURBO_ARGS
705711
else
706712
pnpm turbo test:integration:${{ matrix.test-name }} $TURBO_ARGS
707713
fi
708714
env:
715+
EVENT: ${{ github.event_name }}
709716
CLERK_E2E_OAUTH_PROVIDER_CLIENT_SECRET: ${{ secrets.CLERK_E2E_OAUTH_PROVIDER_CLIENT_SECRET }}
710717
CLERK_PLATFORM_API_KEY: ${{ secrets.CLERK_PLATFORM_API_KEY }}
711718
E2E_DEBUG: '1'

‎.github/workflows/nightly-checks.yml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,7 @@ jobs:
7474
if [ "${{ matrix.test-name }}" == "nextjs" ]; then
7575
E2E_DEBUG=1 E2E_APP_ID=quickstart.next.appRouter pnpm test:integration:base --grep @quickstart 2>&1 | tee "$OUTPUT_FILE"
7676
else
77-
E2E_DEBUG=1 pnpm turbo test:integration:${{ matrix.test-name }} $TURBO_ARGS --only 2>&1 | tee "$OUTPUT_FILE"
77+
E2E_DEBUG=1 pnpm turbo test:integration:${{ matrix.test-name }} $TURBO_ARGS --only --force 2>&1 | tee "$OUTPUT_FILE"
7878
fi
7979
echo "exit_code=${PIPESTATUS[0]}" >> $GITHUB_OUTPUT
8080
env:

‎package.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
"format": "turbo format && node scripts/format-non-workspace.mjs",
1919
"format:check": "turbo format:check && node scripts/format-non-workspace.mjs --check",
2020
"preinstall": "npx only-allow pnpm",
21+
"integration:inputs": "node scripts/integration-test-inputs.mjs",
2122
"integration:secrets": "node ./scripts/1password-keys.mjs",
2223
"lint": "node ./scripts/lint.mjs",
2324
"lint:attw": "FORCE_COLOR=1 turbo lint:attw",
Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,185 @@
1+
import fs from 'node:fs';
2+
import path from 'node:path';
3+
4+
import * as prettier from 'prettier';
5+
6+
const ROOT = path.resolve(import.meta.dirname, '..');
7+
const TURBO_JSON = path.join(ROOT, 'turbo.json');
8+
const INTEGRATION = path.join(ROOT, 'integration');
9+
const TASK_PREFIX = '//#test:integration:';
10+
const RUNTIME_PACKAGES = ['@clerk/clerk-js', '@clerk/ui', '@clerk/testing'];
11+
const DEPENDENCY_FIELDS = ['dependencies', 'devDependencies', 'peerDependencies', 'optionalDependencies'];
12+
const CLERK_PACKAGE = /@clerk\/[a-z0-9-]+/g;
13+
const SKIPPED_DIRS = new Set(['node_modules', '.next', 'dist', 'playwright-report', 'test-results']);
14+
15+
const SUITE_SCOPED_APPS = [
16+
{
17+
suite: 'mosaic',
18+
preset: 'integration/presets/next.ts',
19+
config: 'appRouterMosaic',
20+
template: 'integration/templates/next-app-router-mosaic',
21+
tests: 'integration/tests/mosaic',
22+
},
23+
];
24+
25+
function readWorkspacePackages() {
26+
const packages = new Map();
27+
for (const dir of fs.readdirSync(path.join(ROOT, 'packages'))) {
28+
const manifestPath = path.join(ROOT, 'packages', dir, 'package.json');
29+
if (!fs.existsSync(manifestPath)) {
30+
continue;
31+
}
32+
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
33+
const deps = new Set(DEPENDENCY_FIELDS.flatMap(field => Object.keys(manifest[field] ?? {})));
34+
packages.set(manifest.name, { dir, deps });
35+
}
36+
return packages;
37+
}
38+
39+
function closure(roots, workspace) {
40+
const seen = new Set();
41+
const queue = [...roots].filter(name => workspace.has(name));
42+
while (queue.length) {
43+
const name = queue.pop();
44+
if (seen.has(name)) {
45+
continue;
46+
}
47+
seen.add(name);
48+
for (const dep of workspace.get(name).deps) {
49+
if (workspace.has(dep) && !seen.has(dep)) {
50+
queue.push(dep);
51+
}
52+
}
53+
}
54+
return seen;
55+
}
56+
57+
function* walk(dir, skip) {
58+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
59+
const full = path.join(dir, entry.name);
60+
if (skip.has(path.relative(ROOT, full))) {
61+
continue;
62+
}
63+
if (entry.isDirectory()) {
64+
if (!SKIPPED_DIRS.has(entry.name)) {
65+
yield* walk(full, skip);
66+
}
67+
} else if (entry.isFile()) {
68+
yield full;
69+
}
70+
}
71+
}
72+
73+
function configStatement(source, scoped) {
74+
const match = source.match(new RegExp(`const ${scoped.config} = [\\s\\S]*?;\\n`));
75+
if (!match) {
76+
throw new Error(`Could not find \`const ${scoped.config} = ...;\` in ${scoped.preset}`);
77+
}
78+
return match[0];
79+
}
80+
81+
function referencedPackages(scopedApps) {
82+
const skip = new Set(scopedApps.flatMap(scoped => [scoped.template, scoped.tests]));
83+
const found = new Set(RUNTIME_PACKAGES);
84+
for (const file of walk(INTEGRATION, skip)) {
85+
let source = fs.readFileSync(file, 'utf8');
86+
const relative = path.relative(ROOT, file);
87+
for (const scoped of scopedApps) {
88+
if (relative === scoped.preset) {
89+
source = source.replace(configStatement(source, scoped), '');
90+
}
91+
if (relative.startsWith('integration/tests/') && new RegExp(`\\b${scoped.config}\\b`).test(source)) {
92+
throw new Error(
93+
`${relative} boots \`${scoped.config}\`, which is scoped to the \`${scoped.suite}\` suite. Move the test into ${scoped.tests} or remove the entry from SUITE_SCOPED_APPS.`,
94+
);
95+
}
96+
}
97+
for (const name of source.match(CLERK_PACKAGE) ?? []) {
98+
found.add(name);
99+
}
100+
}
101+
return found;
102+
}
103+
104+
function scopedPackages(scoped) {
105+
const found = new Set();
106+
const presetSource = fs.readFileSync(path.join(ROOT, scoped.preset), 'utf8');
107+
const sources = [configStatement(presetSource, scoped)];
108+
for (const dir of [scoped.template, scoped.tests]) {
109+
for (const file of walk(path.join(ROOT, dir), new Set())) {
110+
sources.push(fs.readFileSync(file, 'utf8'));
111+
}
112+
}
113+
for (const source of sources) {
114+
for (const name of source.match(CLERK_PACKAGE) ?? []) {
115+
found.add(name);
116+
}
117+
}
118+
return found;
119+
}
120+
121+
function excludedPackageGlobs(used, workspace) {
122+
return [...workspace.entries()]
123+
.filter(([name]) => !used.has(name))
124+
.map(([, { dir }]) => `!packages/${dir}/**`)
125+
.sort();
126+
}
127+
128+
function computeExcludes() {
129+
const workspace = readWorkspacePackages();
130+
const shared = closure(referencedPackages(SUITE_SCOPED_APPS), workspace);
131+
const excludes = { default: excludedPackageGlobs(shared, workspace) };
132+
for (const scoped of SUITE_SCOPED_APPS) {
133+
const used = closure([...shared, ...scopedPackages(scoped)], workspace);
134+
excludes[scoped.suite] = excludedPackageGlobs(used, workspace);
135+
}
136+
const scopedDirs = SUITE_SCOPED_APPS.map(scoped => ({
137+
suite: scoped.suite,
138+
globs: [`!${scoped.template}/**`, `!${scoped.tests}/**`],
139+
}));
140+
return { excludes, scopedDirs };
141+
}
142+
143+
function isManaged(input) {
144+
return (
145+
/^!packages\/[^*/]+\/\*\*$/.test(input) ||
146+
SUITE_SCOPED_APPS.some(scoped => input === `!${scoped.template}/**` || input === `!${scoped.tests}/**`)
147+
);
148+
}
149+
150+
async function render() {
151+
const turbo = JSON.parse(fs.readFileSync(TURBO_JSON, 'utf8'));
152+
const { excludes, scopedDirs } = computeExcludes();
153+
for (const [task, config] of Object.entries(turbo.tasks)) {
154+
if (!task.startsWith(TASK_PREFIX)) {
155+
continue;
156+
}
157+
const suite = task.slice(TASK_PREFIX.length);
158+
const otherScopedDirs = scopedDirs.filter(scoped => scoped.suite !== suite).flatMap(scoped => scoped.globs);
159+
config.inputs = [
160+
...config.inputs.filter(input => !isManaged(input)),
161+
...(excludes[suite] ?? excludes.default),
162+
...otherScopedDirs,
163+
];
164+
}
165+
const options = await prettier.resolveConfig(TURBO_JSON);
166+
return prettier.format(JSON.stringify(turbo, null, 2), { ...options, filepath: TURBO_JSON });
167+
}
168+
169+
const next = await render();
170+
const current = fs.readFileSync(TURBO_JSON, 'utf8');
171+
172+
if (process.argv.includes('--check')) {
173+
if (next !== current) {
174+
console.error(
175+
'turbo.json integration test inputs are out of date. Run `pnpm integration:inputs` and commit the result.',
176+
);
177+
process.exit(1);
178+
}
179+
console.log('turbo.json integration test inputs are up to date.');
180+
} else if (next !== current) {
181+
fs.writeFileSync(TURBO_JSON, next);
182+
console.log('Updated integration test inputs in turbo.json.');
183+
} else {
184+
console.log('turbo.json integration test inputs are already up to date.');
185+
}

0 commit comments

Comments
 (0)