diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3a1012b09f7..1b4842dfa6b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -103,7 +103,7 @@ jobs: run: | node --input-type=module <<'EOF' import { appendFileSync } from "node:fs"; - import { listChangedExtensionIds } from "./scripts/test-extension.mjs"; + import { listChangedExtensionIds } from "./scripts/lib/changed-extensions.mjs"; const extensionIds = listChangedExtensionIds({ base: process.env.BASE_SHA, diff --git a/scripts/lib/changed-extensions.mjs b/scripts/lib/changed-extensions.mjs new file mode 100644 index 00000000000..2e6763dd8c4 --- /dev/null +++ b/scripts/lib/changed-extensions.mjs @@ -0,0 +1,136 @@ +import { execFileSync } from "node:child_process"; +import fs from "node:fs"; +import path from "node:path"; +import { BUNDLED_PLUGIN_PATH_PREFIX, BUNDLED_PLUGIN_ROOT_DIR } from "./bundled-plugin-paths.mjs"; + +const repoRoot = path.resolve(import.meta.dirname, "..", ".."); + +function runGit(args, options = {}) { + return execFileSync("git", args, { + cwd: repoRoot, + stdio: ["ignore", "pipe", "pipe"], + encoding: "utf8", + ...options, + }); +} + +function normalizeRelative(inputPath) { + return inputPath.split(path.sep).join("/"); +} + +function hasGitCommit(ref) { + if (!ref || /^0+$/.test(ref)) { + return false; + } + + try { + runGit(["rev-parse", "--verify", `${ref}^{commit}`]); + return true; + } catch { + return false; + } +} + +function resolveChangedPathsBase(params = {}) { + const base = params.base; + const head = params.head ?? "HEAD"; + const fallbackBaseRef = params.fallbackBaseRef; + + if (hasGitCommit(base)) { + return base; + } + + if (fallbackBaseRef) { + const remoteBaseRef = fallbackBaseRef.startsWith("origin/") + ? fallbackBaseRef + : `origin/${fallbackBaseRef}`; + if (hasGitCommit(remoteBaseRef)) { + const mergeBase = runGit(["merge-base", remoteBaseRef, head]).trim(); + if (hasGitCommit(mergeBase)) { + return mergeBase; + } + } + } + + if (!base) { + throw new Error("A git base revision is required to list changed extensions."); + } + + throw new Error(`Git base revision is unavailable locally: ${base}`); +} + +function listChangedPaths(base, head = "HEAD") { + if (!base) { + throw new Error("A git base revision is required to list changed extensions."); + } + + return runGit(["diff", "--name-only", base, head]) + .split("\n") + .map((line) => line.trim()) + .filter((line) => line.length > 0); +} + +function hasExtensionPackage(extensionId) { + return fs.existsSync(path.join(repoRoot, BUNDLED_PLUGIN_ROOT_DIR, extensionId, "package.json")); +} + +export function listAvailableExtensionIds() { + const extensionsDir = path.join(repoRoot, BUNDLED_PLUGIN_ROOT_DIR); + if (!fs.existsSync(extensionsDir)) { + return []; + } + + return fs + .readdirSync(extensionsDir, { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .map((entry) => entry.name) + .filter((extensionId) => hasExtensionPackage(extensionId)) + .toSorted((left, right) => left.localeCompare(right)); +} + +export function detectChangedExtensionIds(changedPaths) { + const extensionIds = new Set(); + + for (const rawPath of changedPaths) { + const relativePath = normalizeRelative(String(rawPath).trim()); + if (!relativePath) { + continue; + } + + const extensionMatch = relativePath.match( + new RegExp(`^${BUNDLED_PLUGIN_PATH_PREFIX.replace("/", "\\/")}([^/]+)(?:/|$)`), + ); + if (extensionMatch) { + const extensionId = extensionMatch[1]; + if (hasExtensionPackage(extensionId)) { + extensionIds.add(extensionId); + } + continue; + } + + const pairedCoreMatch = relativePath.match(/^src\/([^/]+)(?:\/|$)/); + if (pairedCoreMatch && hasExtensionPackage(pairedCoreMatch[1])) { + extensionIds.add(pairedCoreMatch[1]); + } + } + + return [...extensionIds].toSorted((left, right) => left.localeCompare(right)); +} + +export function listChangedExtensionIds(params = {}) { + const head = params.head ?? "HEAD"; + const unavailableBaseBehavior = params.unavailableBaseBehavior ?? "error"; + + try { + const base = resolveChangedPathsBase(params); + return detectChangedExtensionIds(listChangedPaths(base, head)); + } catch (error) { + if (unavailableBaseBehavior === "all") { + return listAvailableExtensionIds(); + } + if (unavailableBaseBehavior === "empty") { + return []; + } + throw error; + } +} diff --git a/scripts/lib/extension-test-plan.mjs b/scripts/lib/extension-test-plan.mjs new file mode 100644 index 00000000000..8f350db10da --- /dev/null +++ b/scripts/lib/extension-test-plan.mjs @@ -0,0 +1,104 @@ +import fs from "node:fs"; +import path from "node:path"; +import { channelTestRoots } from "../../vitest.channel-paths.mjs"; +import { BUNDLED_PLUGIN_PATH_PREFIX, BUNDLED_PLUGIN_ROOT_DIR } from "./bundled-plugin-paths.mjs"; + +const repoRoot = path.resolve(import.meta.dirname, "..", ".."); + +function normalizeRelative(inputPath) { + return inputPath.split(path.sep).join("/"); +} + +function countTestFiles(rootPath) { + let total = 0; + const stack = [rootPath]; + + while (stack.length > 0) { + const current = stack.pop(); + if (!current || !fs.existsSync(current)) { + continue; + } + for (const entry of fs.readdirSync(current, { withFileTypes: true })) { + const fullPath = path.join(current, entry.name); + if (entry.isDirectory()) { + if (entry.name === "node_modules" || entry.name === "dist") { + continue; + } + stack.push(fullPath); + continue; + } + if (entry.isFile() && (fullPath.endsWith(".test.ts") || fullPath.endsWith(".test.tsx"))) { + total += 1; + } + } + } + + return total; +} + +function resolveExtensionDirectory(targetArg, cwd = process.cwd()) { + if (targetArg) { + const asGiven = path.resolve(cwd, targetArg); + if (fs.existsSync(path.join(asGiven, "package.json"))) { + return asGiven; + } + + const byName = path.join(repoRoot, BUNDLED_PLUGIN_ROOT_DIR, targetArg); + if (fs.existsSync(path.join(byName, "package.json"))) { + return byName; + } + + throw new Error( + `Unknown extension target "${targetArg}". Use a plugin name like "slack" or a path inside the bundled plugin workspace tree.`, + ); + } + + let current = cwd; + while (true) { + if ( + normalizeRelative(path.relative(repoRoot, current)).startsWith(BUNDLED_PLUGIN_PATH_PREFIX) && + fs.existsSync(path.join(current, "package.json")) + ) { + return current; + } + const parent = path.dirname(current); + if (parent === current) { + break; + } + current = parent; + } + + throw new Error( + "No extension target provided, and current working directory is not inside the bundled plugin workspace tree.", + ); +} + +export function resolveExtensionTestPlan(params = {}) { + const cwd = params.cwd ?? process.cwd(); + const targetArg = params.targetArg; + const extensionDir = resolveExtensionDirectory(targetArg, cwd); + const extensionId = path.basename(extensionDir); + const relativeExtensionDir = normalizeRelative(path.relative(repoRoot, extensionDir)); + + const roots = [relativeExtensionDir]; + const pairedCoreRoot = path.join(repoRoot, "src", extensionId); + if (fs.existsSync(pairedCoreRoot)) { + roots.push(normalizeRelative(path.relative(repoRoot, pairedCoreRoot))); + } + + const usesChannelConfig = roots.some((root) => channelTestRoots.includes(root)); + const config = usesChannelConfig ? "vitest.channels.config.ts" : "vitest.extensions.config.ts"; + const testFileCount = roots.reduce( + (sum, root) => sum + countTestFiles(path.join(repoRoot, root)), + 0, + ); + + return { + config, + extensionDir: relativeExtensionDir, + extensionId, + hasTests: testFileCount > 0, + roots, + testFileCount, + }; +} diff --git a/scripts/test-extension.mjs b/scripts/test-extension.mjs index 34258374289..5239be1f219 100644 --- a/scripts/test-extension.mjs +++ b/scripts/test-extension.mjs @@ -1,244 +1,16 @@ #!/usr/bin/env node -import { execFileSync, spawn } from "node:child_process"; -import fs from "node:fs"; +import { spawn } from "node:child_process"; import path from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; -import { channelTestRoots } from "../vitest.channel-paths.mjs"; -import { - BUNDLED_PLUGIN_PATH_PREFIX, - BUNDLED_PLUGIN_ROOT_DIR, -} from "./lib/bundled-plugin-paths.mjs"; +import { listAvailableExtensionIds, listChangedExtensionIds } from "./lib/changed-extensions.mjs"; +import { resolveExtensionTestPlan } from "./lib/extension-test-plan.mjs"; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const repoRoot = path.resolve(__dirname, ".."); const pnpm = "pnpm"; -function runGit(args, options = {}) { - return execFileSync("git", args, { - cwd: repoRoot, - stdio: ["ignore", "pipe", "pipe"], - encoding: "utf8", - ...options, - }); -} - -function normalizeRelative(inputPath) { - return inputPath.split(path.sep).join("/"); -} - -function countTestFiles(rootPath) { - let total = 0; - const stack = [rootPath]; - - while (stack.length > 0) { - const current = stack.pop(); - if (!current || !fs.existsSync(current)) { - continue; - } - for (const entry of fs.readdirSync(current, { withFileTypes: true })) { - const fullPath = path.join(current, entry.name); - if (entry.isDirectory()) { - if (entry.name === "node_modules" || entry.name === "dist") { - continue; - } - stack.push(fullPath); - continue; - } - if (entry.isFile() && (fullPath.endsWith(".test.ts") || fullPath.endsWith(".test.tsx"))) { - total += 1; - } - } - } - - return total; -} - -function hasGitCommit(ref) { - if (!ref || /^0+$/.test(ref)) { - return false; - } - - try { - runGit(["rev-parse", "--verify", `${ref}^{commit}`]); - return true; - } catch { - return false; - } -} - -function resolveChangedPathsBase(params = {}) { - const base = params.base; - const head = params.head ?? "HEAD"; - const fallbackBaseRef = params.fallbackBaseRef; - - if (hasGitCommit(base)) { - return base; - } - - if (fallbackBaseRef) { - const remoteBaseRef = fallbackBaseRef.startsWith("origin/") - ? fallbackBaseRef - : `origin/${fallbackBaseRef}`; - if (hasGitCommit(remoteBaseRef)) { - const mergeBase = runGit(["merge-base", remoteBaseRef, head]).trim(); - if (hasGitCommit(mergeBase)) { - return mergeBase; - } - } - } - - if (!base) { - throw new Error("A git base revision is required to list changed extensions."); - } - - throw new Error(`Git base revision is unavailable locally: ${base}`); -} - -function listChangedPaths(base, head = "HEAD") { - if (!base) { - throw new Error("A git base revision is required to list changed extensions."); - } - - return runGit(["diff", "--name-only", base, head]) - .split("\n") - .map((line) => line.trim()) - .filter((line) => line.length > 0); -} - -function hasExtensionPackage(extensionId) { - return fs.existsSync(path.join(repoRoot, BUNDLED_PLUGIN_ROOT_DIR, extensionId, "package.json")); -} - -export function listAvailableExtensionIds() { - const extensionsDir = path.join(repoRoot, BUNDLED_PLUGIN_ROOT_DIR); - if (!fs.existsSync(extensionsDir)) { - return []; - } - - return fs - .readdirSync(extensionsDir, { withFileTypes: true }) - .filter((entry) => entry.isDirectory()) - .map((entry) => entry.name) - .filter((extensionId) => hasExtensionPackage(extensionId)) - .toSorted((left, right) => left.localeCompare(right)); -} - -export function detectChangedExtensionIds(changedPaths) { - const extensionIds = new Set(); - - for (const rawPath of changedPaths) { - const relativePath = normalizeRelative(String(rawPath).trim()); - if (!relativePath) { - continue; - } - - const extensionMatch = relativePath.match( - new RegExp(`^${BUNDLED_PLUGIN_PATH_PREFIX.replace("/", "\\/")}([^/]+)(?:/|$)`), - ); - if (extensionMatch) { - const extensionId = extensionMatch[1]; - if (hasExtensionPackage(extensionId)) { - extensionIds.add(extensionId); - } - continue; - } - - const pairedCoreMatch = relativePath.match(/^src\/([^/]+)(?:\/|$)/); - if (pairedCoreMatch && hasExtensionPackage(pairedCoreMatch[1])) { - extensionIds.add(pairedCoreMatch[1]); - } - } - - return [...extensionIds].toSorted((left, right) => left.localeCompare(right)); -} - -export function listChangedExtensionIds(params = {}) { - const head = params.head ?? "HEAD"; - const unavailableBaseBehavior = params.unavailableBaseBehavior ?? "error"; - - try { - const base = resolveChangedPathsBase(params); - return detectChangedExtensionIds(listChangedPaths(base, head)); - } catch (error) { - if (unavailableBaseBehavior === "all") { - return listAvailableExtensionIds(); - } - if (unavailableBaseBehavior === "empty") { - return []; - } - throw error; - } -} - -function resolveExtensionDirectory(targetArg, cwd = process.cwd()) { - if (targetArg) { - const asGiven = path.resolve(cwd, targetArg); - if (fs.existsSync(path.join(asGiven, "package.json"))) { - return asGiven; - } - - const byName = path.join(repoRoot, BUNDLED_PLUGIN_ROOT_DIR, targetArg); - if (fs.existsSync(path.join(byName, "package.json"))) { - return byName; - } - - throw new Error( - `Unknown extension target "${targetArg}". Use a plugin name like "slack" or a path inside the bundled plugin workspace tree.`, - ); - } - - let current = cwd; - while (true) { - if ( - normalizeRelative(path.relative(repoRoot, current)).startsWith(BUNDLED_PLUGIN_PATH_PREFIX) && - fs.existsSync(path.join(current, "package.json")) - ) { - return current; - } - const parent = path.dirname(current); - if (parent === current) { - break; - } - current = parent; - } - - throw new Error( - "No extension target provided, and current working directory is not inside the bundled plugin workspace tree.", - ); -} - -export function resolveExtensionTestPlan(params = {}) { - const cwd = params.cwd ?? process.cwd(); - const targetArg = params.targetArg; - const extensionDir = resolveExtensionDirectory(targetArg, cwd); - const extensionId = path.basename(extensionDir); - const relativeExtensionDir = normalizeRelative(path.relative(repoRoot, extensionDir)); - - const roots = [relativeExtensionDir]; - const pairedCoreRoot = path.join(repoRoot, "src", extensionId); - if (fs.existsSync(pairedCoreRoot)) { - const pairedRelativeRoot = normalizeRelative(path.relative(repoRoot, pairedCoreRoot)); - roots.push(pairedRelativeRoot); - } - - const usesChannelConfig = roots.some((root) => channelTestRoots.includes(root)); - const config = usesChannelConfig ? "vitest.channels.config.ts" : "vitest.extensions.config.ts"; - const testFileCount = roots.reduce( - (sum, root) => sum + countTestFiles(path.join(repoRoot, root)), - 0, - ); - return { - config, - extensionDir: relativeExtensionDir, - extensionId, - hasTests: testFileCount > 0, - roots, - testFileCount, - }; -} - async function runVitestBatch(params) { return await new Promise((resolve, reject) => { const child = spawn( diff --git a/test/scripts/test-extension.test.ts b/test/scripts/test-extension.test.ts index 136817e112f..c325c8e2a2f 100644 --- a/test/scripts/test-extension.test.ts +++ b/test/scripts/test-extension.test.ts @@ -5,8 +5,8 @@ import { detectChangedExtensionIds, listAvailableExtensionIds, listChangedExtensionIds, - resolveExtensionTestPlan, -} from "../../scripts/test-extension.mjs"; +} from "../../scripts/lib/changed-extensions.mjs"; +import { resolveExtensionTestPlan } from "../../scripts/lib/extension-test-plan.mjs"; import { bundledPluginFile, bundledPluginRoot } from "../helpers/bundled-plugin-paths.js"; const scriptPath = path.join(process.cwd(), "scripts", "test-extension.mjs"); @@ -55,7 +55,7 @@ describe("scripts/test-extension.mjs", () => { expect(plan.hasTests).toBe(true); }); - it("keeps extension-root plans lean when there is no paired core test root", () => { + it("omits src/ when no paired core root exists", () => { const plan = resolveExtensionTestPlan({ targetArg: "line", cwd: process.cwd() }); expect(plan.roots).toContain(bundledPluginRoot("line"));