#!/usr/bin/env node /** * Detects which Playwright e2e spec files to run based on changed source files. * * Convention: every diagram type has a matching subfolder under * e2e/rendering//. Adding a new spec file to * that subfolder requires no configuration here — it is discovered at runtime. * Diagram fixtures live under `e2e/diagrams//*.mmd` and are run by the * global runner e2e/rendering/mmd-snapshots.spec.ts; a fixture-only change * scopes to that runner. * * CLI usage (reads changed file paths from stdin, one per line): * git diff --name-only HEAD | node scripts/e2e-diagram-scope.mjs * * Output: * - A comma-separated list of spec patterns (for playwright test), OR * - An empty string if the full suite should run (shared code changed, or * unable to confidently scope the change) * * Module usage: * import { detectScope } from './e2e-diagram-scope.mjs'; * const spec = detectScope(['packages/mermaid/src/diagrams/flowchart/flowchart.ts']); * // => 'e2e/rendering/flowchart/' */ import { createInterface } from 'readline'; import { existsSync, readdirSync } from 'fs'; import { fileURLToPath } from 'url'; // Base directory where diagram spec subfolders live export const SPEC_BASE_DIR = 'e2e/rendering'; // Where the global mmd snapshot runner's fixtures live. export const DIAGRAMS_DIR = 'e2e/diagrams'; // Source diagram folders mix kebab- and camelCase (quadrant-chart, treeView) // while the fixture folders are kebab (tree-view); compare on a canonical // lowercased, hyphen-stripped name. const canonicalName = (name) => name.toLowerCase().replace(/-/g, ''); // Sentinel value returned when all changed files are ignorable (e.g. // docs-only PRs). Consumers should skip e2e entirely when they receive this. export const SKIP = 'SKIP'; // --------------------------------------------------------------------------- // Ignorable paths: files that can NEVER affect rendered diagram output. // When a changed file matches one of these, it is silently skipped (continue) // rather than triggering the full suite. // --------------------------------------------------------------------------- const IGNORABLE_PREFIXES = [ // Root-level repo metadata 'img/', 'CITATION.cff', 'FUNDING.json', 'LICENSE', 'CODE_OF_CONDUCT.md', 'CONTRIBUTING.md', 'README.md', 'README.zh-CN.md', // Config/tooling that doesn't affect rendering 'renovate.json', 'docker-compose.yml', 'Dockerfile', 'netlify.toml', 'cspell.config.yaml', // Documentation (source and generated) 'packages/mermaid/src/docs/', 'packages/mermaid/src/vitepress/', 'packages/examples/', 'packages/mermaid-local-editor/', 'packages/tiny/', 'docs/', // Changeset descriptions '.changeset/', // AI assistant / agent config '.claude/', 'assistant/', // GitHub metadata that doesn't affect rendering '.github/workflows/build-docs.yml', '.github/workflows/publish-docs.yml', '.github/ISSUE_TEMPLATE/', '.github/CODEOWNERS', '.github/FUNDING.yml', '.github/workflows/autofix.yml', '.github/workflows/check-readme-in-sync.yml', '.github/workflows/codeql.yml', '.github/workflows/dependency-review.yml', '.github/workflows/issue-triage.yml', '.github/workflows/link-checker.yml', '.github/workflows/lint.yml', '.github/workflows/pr-labeler.yml', '.github/workflows/renovatebot-config-lint.yml', '.github/workflows/scorecard.yml', '.github/workflows/unlock-reopened-issues.yml', '.github/workflows/update-browserlist.yml', '.github/workflows/validate-lockfile.yml', // Doc-related scripts 'packages/mermaid/scripts/docs', // Demos 'demos/', ]; // Files ending with these suffixes are ignorable UNLESS they live inside a // diagram source folder (where even a .md might be a samples file that // signals intent to test). const IGNORABLE_SUFFIXES = ['.md']; // --------------------------------------------------------------------------- // Paths: if ANY changed file matches one of these prefixes, fall back to the // full suite. // --------------------------------------------------------------------------- const SHARED_PREFIXES = [ // Shared diagram utilities (used by all diagram types) 'packages/mermaid/src/diagrams/common/', // Shared rendering utilities (shapes, edges, layout — affects all diagrams) 'packages/mermaid/src/rendering-util/', // Themes (affects all visual output) 'packages/mermaid/src/themes/', // Core API and config (affects all diagrams) 'packages/mermaid/src/config', 'packages/mermaid/src/mermaid', 'packages/mermaid/src/mermaidAPI', 'packages/mermaid/src/diagram-api/', 'packages/mermaid/src/Diagram.', 'packages/mermaid/src/schemas/', // Parser package (used by multiple diagram types via Langium) 'packages/parser/', // Layout engine packages 'packages/dagre-wrapper/', 'packages/mermaid-layout-elk/', 'packages/mermaid-layout-tidy-tree/', // Plugin packages (separate bundles but tested in main suite) 'packages/mermaid-zenuml/', 'packages/mermaid-example-diagram/', // Playwright: config and cross-cutting test utilities 'e2e/other/', 'e2e/helpers/', 'playwright.config', // Build and tooling (may affect all output) '.esbuild/', '.vite/', 'vitest.', 'tsconfig', ]; // Regex: extract diagram name from paths like // packages/mermaid/src/diagrams//... const DIAGRAM_PATH_RE = /^packages\/mermaid\/src\/diagrams\/([^/]+)\//; // --------------------------------------------------------------------------- // Core detection function // --------------------------------------------------------------------------- /** * Given a list of changed file paths (relative to repo root), returns the * comma-separated spec pattern to pass to `playwright test`, or an empty * string if the full suite should run. * * The function uses the filesystem to discover which diagram spec subfolders * exist. No mapping table is maintained — adding a spec to a subfolder is * sufficient. * * @param {string[]} files - Changed file paths (relative to repo root) * @param {{ specBaseDir?: string }} [options] * @returns {string} Spec pattern, or '' for full suite */ export function detectScope(files, options = {}) { const specBaseDir = options.specBaseDir ?? SPEC_BASE_DIR; if (files.length === 0) { return ''; } /** @type {Set} */ const diagramNames = new Set(); let touchesShared = false; // A changed e2e/diagrams/**/*.mmd fixture is exercised by the global mmd // snapshot runner (e2e/rendering/mmd-snapshots.spec.ts), not by a per-diagram // spec — track it so such a change scopes to that runner instead of the full // suite. let touchesMmdFixtures = false; /** @type {string[]} */ const directlyChangedSpecs = []; for (const file of files) { const trimmed = file.trim(); if (!trimmed) { continue; } // Shared infrastructure → full suite if (SHARED_PREFIXES.some((prefix) => trimmed.startsWith(prefix))) { touchesShared = true; break; } // File inside a diagram folder const diagramMatch = DIAGRAM_PATH_RE.exec(trimmed); if (diagramMatch) { const name = diagramMatch[1]; // 'common' and 'globalStyles' are shared — already caught above, // but guard here for safety if (name === 'common' || name === 'globalStyles') { touchesShared = true; break; } diagramNames.add(name); continue; } // File under the spec base directory const specFolderPrefix = `${specBaseDir}/`; if (trimmed.startsWith(specFolderPrefix)) { const rest = trimmed.slice(specFolderPrefix.length); const slashIdx = rest.indexOf('/'); if (slashIdx === -1) { // No subfolder separator → file sits at the root of SPEC_BASE_DIR. // Root-level specs are cross-cutting by convention; full suite. touchesShared = true; break; } // File inside a diagram subfolder → scope to that subfolder. Playwright // treats CLI filters as regular expressions, so keep this glob-free. const subFolder = rest.slice(0, slashIdx); directlyChangedSpecs.push(`${specFolderPrefix}${subFolder}/`); continue; } // A diagram fixture (.mmd) consumed by the global mmd snapshot runner. if (trimmed.startsWith('e2e/diagrams/')) { touchesMmdFixtures = true; continue; } // Ignorable files (docs, changesets, AI config, etc.) → skip silently. // Guard: .md files inside a diagram source folder are NOT ignorable — they // may be samples or signal intent, and their diagram folder was already // handled by DIAGRAM_PATH_RE above. if ( IGNORABLE_PREFIXES.some((prefix) => trimmed.startsWith(prefix)) || (IGNORABLE_SUFFIXES.some((suffix) => trimmed.endsWith(suffix)) && !DIAGRAM_PATH_RE.test(trimmed)) ) { continue; } // Anything else (root config, CI YAML, e2e/other, etc.) → full suite touchesShared = true; break; } if (touchesShared) { return ''; } // Build regular-expression-safe spec patterns from diagram names using // filesystem discovery. A trailing slash matches every file in the folder. const specs = new Set(directlyChangedSpecs); /** @type {Set | null} */ let fixtureFolders = null; const hasFixtures = (name) => { if (fixtureFolders === null) { try { fixtureFolders = new Set( readdirSync(DIAGRAMS_DIR, { withFileTypes: true }) .filter((entry) => entry.isDirectory()) .map((entry) => canonicalName(entry.name)) ); } catch { fixtureFolders = new Set(); } } return fixtureFolders.has(canonicalName(name)); }; for (const name of diagramNames) { const folder = `${specBaseDir}/${name}`; if (existsSync(folder)) { specs.add(`${folder}/`); } else if (hasFixtures(name)) { // Fixtures-only diagram (no per-diagram spec subfolder) — the global mmd // snapshot runner exercises it. touchesMmdFixtures = true; } else { // Neither a spec subfolder nor fixtures — be safe and run the full suite // rather than silently skip tests for an unrecognised diagram. return ''; } } if (specs.size === 0 && !touchesMmdFixtures) { // All files were either ignorable or empty — no e2e tests needed. return SKIP; } // Any scoped diagram run — and any fixture-only change — executes the shared // mmd snapshot runner. specs.add(`${specBaseDir}/mmd-snapshots.spec.ts`); return [...specs].join(','); } // --------------------------------------------------------------------------- // CLI entry point: read changed files from stdin, write spec to stdout // --------------------------------------------------------------------------- if (process.argv[1] === fileURLToPath(import.meta.url)) { const lines = []; const rl = createInterface({ input: process.stdin, crlfDelay: Infinity }); for await (const line of rl) { lines.push(line); } const result = detectScope(lines); process.stdout.write(result); }