diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 000000000..3528137d1 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,155 @@ +name: Build + +on: + push: + branches: + - main + + pull_request: + + # Allow `uses: ...` in other workflows, used by `release.yml` + workflow_call: + + # Allow users with repo write to run the workflow from the GitHub Actions UI + workflow_dispatch: + +jobs: + build: + runs-on: ${{ matrix.os }} + + strategy: + matrix: + os: [ubuntu-latest, windows-latest] + node-version: [20, 22, 24] + + steps: + # Prevent LF→CRLF conversion on Windows checkout (causes prettier to warn). + # Linux and macOS are false by default. TODO: gitattributes + - name: Set git to use LF + run: | + git config --global core.autocrlf false + git config --global core.eol lf + + - uses: actions/checkout@v6 + + - uses: actions/setup-node@v6 + with: + node-version: ${{ matrix.node-version }} + cache: npm + + - run: node scripts/validate-platform-dependencies.js + + - run: npm clean-install + + - name: Check version consistency + run: npm run check-version + + - name: Check formatting and linting + run: npm run lint + + - name: Run client tests + working-directory: ./client + run: npm test + + - run: npm run build + + # When users `npm install` the Inspector, npm will try to flatten/deduplicate dependencies across + # their entire project, and could choose newer versions than our lock file (within our semver + # constraints). This test helps catch when those newer versions break the build. + dependency-range-test: + runs-on: ubuntu-latest + + # Don't block if this fails - it's informational + continue-on-error: true + + steps: + - uses: actions/checkout@v6 + + - uses: actions/setup-node@v6 + with: + node-version-file: package.json + # Omit cache to test with freshly-resolved dependencies + + - name: Install dependencies with fresh resolution + run: npm install --no-package-lock + + - name: Check version consistency + run: npm run check-version + + - name: Check formatting and linting + run: npm run lint + + - name: Run client tests + working-directory: ./client + run: npm test + + - run: npm run build + + e2e-tests: + # Installing Playwright dependencies can take quite awhile, and also depends on GitHub CI load. + timeout-minutes: 15 + runs-on: ubuntu-latest + + steps: + - name: Install dependencies + run: | + sudo apt-get update + sudo apt-get install -y libwoff1 + + - uses: actions/checkout@v6 + + - uses: actions/setup-node@v6 + id: setup_node + with: + node-version-file: package.json + cache: npm + + # Cache Playwright browsers + - name: Cache Playwright browsers + id: cache-playwright + uses: actions/cache@v4 + with: + path: ~/.cache/ms-playwright # The default Playwright cache path + key: ${{ runner.os }}-playwright-${{ hashFiles('package-lock.json') }} # Cache key based on OS and package-lock.json + restore-keys: | + ${{ runner.os }}-playwright- + + - name: Install dependencies + run: npm ci + + - name: Install Playwright dependencies + run: npx playwright install-deps + + - name: Install Playwright and browsers unless cached + run: npx playwright install --with-deps + if: steps.cache-playwright.outputs.cache-hit != 'true' + + - name: Run Playwright tests + id: playwright-tests + run: npm run test:e2e + + - name: Upload Playwright Report and Screenshots + uses: actions/upload-artifact@v4 + if: steps.playwright-tests.conclusion != 'skipped' + with: + name: playwright-report + path: | + client/playwright-report/ + client/test-results/ + client/results.json + retention-days: 2 + + - name: Publish Playwright Test Summary + uses: daun/playwright-report-summary@v3 + if: steps.playwright-tests.conclusion != 'skipped' + with: + create-comment: ${{ github.event.pull_request.head.repo.full_name == github.repository }} + report-file: client/results.json + comment-title: "Playwright test results" + custom-info: | + + **Environment:** Ubuntu Latest, Node.js ${{ steps.setup_node.outputs.node-version }} + **Browsers:** Chromium, Firefox + + 📊 [View Detailed HTML Report](https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}) (download artifacts) + test-command: "npm run test:e2e" diff --git a/.github/workflows/claude.yml b/.github/workflows/claude.yml index 2910e4f31..896adaf33 100644 --- a/.github/workflows/claude.yml +++ b/.github/workflows/claude.yml @@ -28,7 +28,7 @@ jobs: actions: read steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: fetch-depth: 1 diff --git a/.github/workflows/cli_tests.yml b/.github/workflows/cli_tests.yml index 8bd3bb8ec..2d321c3ad 100644 --- a/.github/workflows/cli_tests.yml +++ b/.github/workflows/cli_tests.yml @@ -15,10 +15,10 @@ jobs: run: working-directory: ./cli steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Set up Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@v6 with: node-version-file: package.json cache: npm diff --git a/.github/workflows/e2e_tests.yml b/.github/workflows/e2e_tests.yml deleted file mode 100644 index 378905b44..000000000 --- a/.github/workflows/e2e_tests.yml +++ /dev/null @@ -1,78 +0,0 @@ -name: Playwright Tests - -on: - push: - branches: [main] - pull_request: - branches: [main] - -jobs: - test: - # Installing Playwright dependencies can take quite awhile, and also depends on GitHub CI load. - timeout-minutes: 15 - runs-on: ubuntu-latest - - steps: - - name: Install dependencies - run: | - sudo apt-get update - sudo apt-get install -y libwoff1 - - - uses: actions/checkout@v4 - - - uses: actions/setup-node@v4 - id: setup_node - with: - node-version-file: package.json - cache: npm - - # Cache Playwright browsers - - name: Cache Playwright browsers - id: cache-playwright - uses: actions/cache@v4 - with: - path: ~/.cache/ms-playwright # The default Playwright cache path - key: ${{ runner.os }}-playwright-${{ hashFiles('package-lock.json') }} # Cache key based on OS and package-lock.json - restore-keys: | - ${{ runner.os }}-playwright- - - - name: Install dependencies - run: npm ci - - - name: Install Playwright dependencies - run: npx playwright install-deps - - - name: Install Playwright and browsers unless cached - run: npx playwright install --with-deps - if: steps.cache-playwright.outputs.cache-hit != 'true' - - - name: Run Playwright tests - id: playwright-tests - run: npm run test:e2e - - - name: Upload Playwright Report and Screenshots - uses: actions/upload-artifact@v4 - if: steps.playwright-tests.conclusion != 'skipped' - with: - name: playwright-report - path: | - client/playwright-report/ - client/test-results/ - client/results.json - retention-days: 2 - - - name: Publish Playwright Test Summary - uses: daun/playwright-report-summary@v3 - if: steps.playwright-tests.conclusion != 'skipped' - with: - create-comment: ${{ github.event.pull_request.head.repo.full_name == github.repository }} - report-file: client/results.json - comment-title: "🎭 Playwright E2E Test Results" - job-summary: true - icon-style: "emojis" - custom-info: | - **Test Environment:** Ubuntu Latest, Node.js ${{ steps.setup_node.outputs.node-version }} - **Browsers:** Chromium, Firefox - - 📊 [View Detailed HTML Report](https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}) (download artifacts) - test-command: "npm run test:e2e" diff --git a/.github/workflows/main.yml b/.github/workflows/release.yml similarity index 64% rename from .github/workflows/main.yml rename to .github/workflows/release.yml index d01f7175b..63a3af157 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/release.yml @@ -1,47 +1,16 @@ -on: - push: - branches: - - main +name: Release - pull_request: +on: release: + # `published` triggers for both `released` and `prereleased`, even from a saved draft types: [published] jobs: build: - runs-on: ubuntu-latest - - steps: - - uses: actions/checkout@v4 - - - name: Check formatting - run: npx prettier --check . - - - uses: actions/setup-node@v4 - with: - node-version-file: package.json - cache: npm - - # Working around https://github.com/npm/cli/issues/4828 - # - run: npm ci - - run: npm install --no-package-lock - - - name: Check version consistency - run: npm run check-version - - - name: Check linting - working-directory: ./client - run: npm run lint - - - name: Run client tests - working-directory: ./client - run: npm test - - - run: npm run build + uses: ./.github/workflows/build.yml publish: runs-on: ubuntu-latest - if: github.event_name == 'release' environment: release needs: build @@ -50,16 +19,15 @@ jobs: id-token: write steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 + - uses: actions/checkout@v6 + + - uses: actions/setup-node@v6 with: node-version-file: package.json cache: npm registry-url: "https://registry.npmjs.org" - # Working around https://github.com/npm/cli/issues/4828 - # - run: npm ci - - run: npm install --no-package-lock + - run: npm clean-install # TODO: Add --provenance once the repo is public - run: npm run publish-all @@ -68,16 +36,17 @@ jobs: publish-github-container-registry: runs-on: ubuntu-latest - if: github.event_name == 'release' environment: release needs: build + permissions: contents: read packages: write attestations: write id-token: write + steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Log in to the Container registry uses: docker/login-action@v3 diff --git a/.husky/pre-commit b/.husky/pre-commit index 0a3afdcb6..ff2270db4 100644 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -1,2 +1,3 @@ npx lint-staged git update-index --again +.husky/scripts/validate-lockfile.sh diff --git a/.husky/scripts/validate-lockfile.sh b/.husky/scripts/validate-lockfile.sh new file mode 100755 index 000000000..ce0893a0a --- /dev/null +++ b/.husky/scripts/validate-lockfile.sh @@ -0,0 +1,26 @@ +#!/bin/sh +# Only run if package-lock.json is being committed +if ! git diff --cached --name-only | grep -q "^package-lock.json$"; then + exit 0 +fi + +# Check npm version +NPM_VERSION=$(npm --version) +NPM_MAJOR=$(echo "$NPM_VERSION" | cut -d. -f1) +NPM_MINOR=$(echo "$NPM_VERSION" | cut -d. -f2) + +if [ "$NPM_MAJOR" -lt 11 ] || ([ "$NPM_MAJOR" -eq 11 ] && [ "$NPM_MINOR" -lt 3 ]); then + echo "" + echo "⚠️ You are using npm $NPM_VERSION" + echo "npm >= 11.3.0 is recommended to avoid lockfile issues." + echo "See: https://github.com/npm/cli/issues/4828" + echo "" +fi + +# Validate lockfile +node scripts/validate-platform-dependencies.js || { + echo "" + echo "❌ package-lock.json validation failed" + echo "Run with --add-missing to fix, or upgrade to npm >= 11.3.0 and regenerate." + exit 1 +} diff --git a/package.json b/package.json index 88023563d..821f56fbc 100644 --- a/package.json +++ b/package.json @@ -76,7 +76,8 @@ "get-intrinsic": "1.3.0" }, "engines": { - "node": ">=22.7.5" + "node": "^20.17.0 || >=22.9.0", + "npm": ">=11.3.0" }, "lint-staged": { "**/*.{js,ts,jsx,tsx,json,md}": [ diff --git a/scripts/validate-platform-dependencies.js b/scripts/validate-platform-dependencies.js new file mode 100644 index 000000000..bcf47d0d3 --- /dev/null +++ b/scripts/validate-platform-dependencies.js @@ -0,0 +1,361 @@ +/* + * Validates `package-lock.json` contains resolution metadata + * (resolved/integrity) for ALL optional platform-specific dependencies, not + * just the current platform. + * + * WHY: + * + * npm < 11.3.0 has a bug (https://github.com/npm/cli/issues/4828) where + * running `npm install` under specific conditions generates a lockfile that + * includes optional dependencies but omits resolution metadata for + * non-current platforms. + * + * NOTE: npm 11.3.0+ (Apr 8, 2025) fixes this bug. + * + * BUG CONDITIONS: + * + * - no `package-lock.json` + * - `node_modules` exists with packages for current platform + * + * When `npm install` runs in this state, it includes all platform-specific + * optional dependencies, but only resolves them for the current platform. + * Other platforms remain unresolved. + * + * This breaks cross-platform compatibility - when developers on different + * OSes or CI systems run `npm install`, npm skips installing the unresolved + * platform-specific dependencies for their platform. + * + * SCENARIOS: + * + * - Changing package managers (e.g., yarn → npm use different lock files) + * + * - Resolving complex `package-lock.json` merge conflicts by deleting and + * regenerating (generally better to fix conflicts on respective branches) + * + * USAGE: + * + * `node scripts/validate-platform-dependencies.js` + * + * Exits with error if cross-platform resolution metadata cannot be found. + * + * Suggests fixes: + * + * 1. Upgrade to npm >= 11.3.0 and regenerate lockfile (preferred) + * 2. Run with --add-missing to fetch from registry + * + */ + +import fs from "node:fs"; +import path from "node:path"; +import process from "node:process"; +import { execSync } from "node:child_process"; + +const LOCKFILE_NAME = "package-lock.json"; + +// ANSI color codes +const COLORS = { + RED: "\x1b[31m", + RESET: "\x1b[0m", +}; + +async function main() { + const cwd = process.cwd(); + + // Parse command line arguments + const args = process.argv.slice(2); + const addMissing = args.includes("--add-missing"); + + // Parse --lockfile argument + const lockfileArg = args.find((arg) => arg.startsWith("--lockfile=")); + const lockPath = lockfileArg + ? path.resolve(cwd, lockfileArg.split("=")[1]) + : path.resolve(cwd, LOCKFILE_NAME); + + if (!fs.existsSync(lockPath)) { + console.error(`❌ No lockfile found at ${lockPath}`); + process.exit(1); + } + + const lockfile = JSON.parse(fs.readFileSync(lockPath, "utf8")); + const packages = lockfile.packages || {}; + + // 1. Build the Dependency Graph & Index Resolved Names + const resolvedNames = new Set(); + const parentMap = new Map(); + + Object.entries(packages).forEach(([pkgPath, entry]) => { + // 1a. Index names + if (pkgPath !== "") { + const name = getPackageNameFromPath(pkgPath); + if (name) resolvedNames.add(name); + } + + // 1b. Build Parent Map (Who depends on me?) + // Must include all dependency types to ensure we can trace the graph + const allDeps = { + ...entry.dependencies, + ...entry.devDependencies, + ...entry.peerDependencies, + ...entry.optionalDependencies, + }; + + Object.keys(allDeps).forEach((depName) => { + // Basic hoisting resolution logic + let childPath = `node_modules/${depName}`; + + // Check for nested resolution (shadowing) + const nestedPath = `${pkgPath}/node_modules/${depName}`; + if (packages[nestedPath]) { + childPath = nestedPath; + } + + if (!parentMap.has(childPath)) { + parentMap.set(childPath, new Set()); + } + parentMap.get(childPath).add(pkgPath); + }); + }); + + // 2. Identify Broken Packages + const brokenPackages = new Set(); + + Object.entries(packages).forEach(([pkgPath, entry]) => { + if (!entry.optionalDependencies) return; + + Object.keys(entry.optionalDependencies).forEach((depName) => { + if (!resolvedNames.has(depName)) { + brokenPackages.add(pkgPath); + } + }); + }); + + // 3. Trace back to Workspace Roots (The Fix) + const fixes = new Map(); + + brokenPackages.forEach((brokenPath) => { + const rootOwner = traceToWorkspace(brokenPath, parentMap); + + if (rootOwner) { + const { workspace, directDependency } = rootOwner; + + if (!fixes.has(workspace)) { + fixes.set(workspace, new Map()); + } + + // We reinstall the DIRECT DEPENDENCY (e.g. vite), not the broken child (esbuild) + const depEntry = packages[directDependency]; + const name = getPackageNameFromPath(directDependency); + + if (name && depEntry && depEntry.version) { + fixes.get(workspace).set(name, depEntry.version); + } + } + }); + + // 4. Report or fix missing platform dependencies + if (fixes.size === 0) { + console.log("✅ All platform-specific dependencies are properly resolved."); + return; + } + + console.log(`${COLORS.RED}%s${COLORS.RESET}\n`, "⚠️ MISSING PACKAGES"); + console.log( + "Resolution metadata is missing for cross-platform optional dependencies.\n", + ); + + // Find all missing optional dependencies + const missingPackages = new Map(); // packageName@version -> [parent paths] + + brokenPackages.forEach((brokenPath) => { + const entry = packages[brokenPath]; + if (!entry.optionalDependencies) return; + + Object.keys(entry.optionalDependencies).forEach((depName) => { + if (!resolvedNames.has(depName)) { + const version = entry.optionalDependencies[depName]; + const key = `${depName}@${version}`; + + if (!missingPackages.has(key)) { + missingPackages.set(key, []); + } + missingPackages.get(key).push(brokenPath); + } + }); + }); + + console.log(`${missingPackages.size} missing package(s):\n`); + + // Show which top-level packages depend on the broken ones + console.log("📦 Top-level dependencies requiring these packages:\n"); + + // Check if there are workspaces (more than just root, or any non-root workspace) + const hasWorkspaces = fixes.size > 1 || (fixes.size === 1 && !fixes.has("")); + + for (const [wsPath, pkgMap] of fixes.entries()) { + const pkgs = Array.from(pkgMap.entries()) + .map(([name, ver]) => `${name}@${ver}`) + .join(", "); + + if (hasWorkspaces) { + const wsName = wsPath === "" ? "root" : wsPath; + console.log(` [${wsName}]: ${pkgs}`); + } else { + // No workspaces - just list packages without [root] prefix + console.log(` ${pkgs}`); + } + } + + // Group by package family for cleaner output + console.log("\n🔍 Missing packages by family:\n"); + const packageFamilies = new Map(); + for (const pkgSpec of missingPackages.keys()) { + const [name] = + pkgSpec.split("@").filter(Boolean).length === 2 + ? pkgSpec.match(/^(@?[^@]+)@(.+)$/).slice(1) + : [pkgSpec, ""]; + const family = name.split("/")[0]; + if (!packageFamilies.has(family)) { + packageFamilies.set(family, []); + } + packageFamilies.get(family).push(pkgSpec); + } + + for (const [family, packages] of packageFamilies.entries()) { + console.log(` ${family}:`); + packages.forEach((pkg) => console.log(` - ${pkg}`)); + console.log(""); + } + + if (!addMissing) { + // Report mode - just show what's missing and how to fix + console.log("\n📋 Actions you can take:\n"); + console.log( + " 1. Run this script with `--add-missing` to automatically fetch and add entries:\n", + ); + console.log( + " `node validate-platform-dependencies.js --add-missing`\n\n", + ); + console.log( + " 2. Upgrade to npm >= 11.3.0 which has a fix for this issue, and regenerate the lockfile\n", + ); + process.exit(1); + } + + // Add missing mode - fetch from registry and add to lockfile + console.log("\n🔧 Fetching missing packages from npm registry...\n"); + + let addedCount = 0; + + for (const [pkgSpec, parents] of missingPackages.entries()) { + const [name, version] = + pkgSpec.split("@").filter(Boolean).length === 2 + ? pkgSpec.match(/^(@?[^@]+)@(.+)$/).slice(1) + : [pkgSpec, ""]; + + console.log(` Fetching ${name}@${version}...`); + + try { + // Fetch package metadata from npm registry + const url = `https://registry.npmjs.org/${name}/${version}`; + const response = await fetch(url); + + if (!response.ok) { + console.error( + ` ❌ Failed to fetch: ${response.status} ${response.statusText}`, + ); + continue; + } + + const data = await response.json(); + + // Construct lockfile entry + const pkgPath = `node_modules/${name}`; + + packages[pkgPath] = { + version: data.version, + resolved: data.dist.tarball, + integrity: data.dist.integrity, + cpu: data.cpu || undefined, + license: data.license || undefined, + optional: true, + os: data.os || undefined, + engines: data.engines || undefined, + }; + + // Clean up undefined fields + Object.keys(packages[pkgPath]).forEach((key) => { + if (packages[pkgPath][key] === undefined) { + delete packages[pkgPath][key]; + } + }); + + console.log(` ✓ Added ${pkgPath}`); + addedCount++; + } catch (error) { + console.error(` ❌ Error fetching ${name}@${version}:`, error.message); + } + } + + if (addedCount > 0) { + // Write updated lockfile + console.log( + `\n💾 Writing updated lockfile with ${addedCount} new entries...`, + ); + fs.writeFileSync( + lockPath, + JSON.stringify(lockfile, null, 2) + "\n", + "utf8", + ); + console.log("\n✅ Done! Verify with: git diff package-lock.json"); + console.log( + "Expected: new platform entries added without any version changes\n", + ); + } else { + console.error("\n❌ No packages were added. Check errors above.\n"); + process.exit(1); + } +} + +// --- Helpers --- + +function getPackageNameFromPath(pkgPath) { + // Fix: Use lastIndexOf to catch "node_modules/" at start of string or middle + const index = pkgPath.lastIndexOf("node_modules/"); + if (index !== -1) { + return pkgPath.substring(index + 13); // 13 is length of "node_modules/" + } + return pkgPath; +} + +function traceToWorkspace(startPath, parentMap) { + const queue = [{ path: startPath, child: startPath }]; + const visited = new Set(); + + while (queue.length > 0) { + const { path: current, child } = queue.shift(); + if (visited.has(current)) continue; + visited.add(current); + + // If current path doesn't contain "node_modules", it's a workspace path (e.g. "client") + // OR if it is the explicit root string "" + const isWorkspace = !current.includes("node_modules") || current === ""; + + if (isWorkspace) { + return { + workspace: current, + directDependency: child, + }; + } + + const parents = parentMap.get(current); + if (parents) { + for (const parent of parents) { + // We track 'child' so we know which dependency connects to the workspace + queue.push({ path: parent, child: current }); + } + } + } + return null; +} + +main();