|
| 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