diff --git a/.changeset/zero-config-auto-mode.md b/.changeset/zero-config-auto-mode.md new file mode 100644 index 000000000..2e3c68b5e --- /dev/null +++ b/.changeset/zero-config-auto-mode.md @@ -0,0 +1,8 @@ +--- +"@node-minify/action": minor +"@node-minify/utils": minor +--- + +feat: add zero-config auto mode for GitHub Action with smart file discovery + +Adds `auto: true` mode that automatically discovers and minifies files without explicit input/output configuration. Includes smart file type detection, default glob patterns for common source directories, and comprehensive ignore patterns. Also adds ignore patterns support to the wildcards utility function. diff --git a/.github/workflows/test-action.yml b/.github/workflows/test-action.yml index 10ed8031f..1bbd5d87d 100644 --- a/.github/workflows/test-action.yml +++ b/.github/workflows/test-action.yml @@ -101,6 +101,11 @@ jobs: input: "test-action/input.js" output: "test-action/output.min.js" compressor: "terser" + auto: 'false' + patterns: '' + output-dir: 'dist' + ignore: '' + dry-run: 'false' - name: Verify terser outputs env: @@ -137,6 +142,11 @@ jobs: output: "test-action/output.esbuild.js" compressor: "esbuild" type: "js" + auto: 'false' + patterns: '' + output-dir: 'dist' + ignore: '' + dry-run: 'false' - name: Compare results env: @@ -217,6 +227,11 @@ jobs: output: "test-action/output.min.css" compressor: "lightningcss" type: "css" + auto: 'false' + patterns: '' + output-dir: 'dist' + ignore: '' + dry-run: 'false' - name: Verify CSS output run: | @@ -231,10 +246,161 @@ jobs: cat test-action/output.min.css + test-zero-config: + name: Test Zero Config (Auto Mode) + runs-on: ubuntu-latest + needs: build-action + steps: + - uses: actions/checkout@v4 + + - name: Download built action + uses: actions/download-artifact@v4 + with: + name: action-dist + path: packages/action/dist/ + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: "20" + + - name: Install compressors + run: | + mkdir -p /tmp/compressors && cd /tmp/compressors + npm init -y + npm install @node-minify/terser @node-minify/lightningcss + mkdir -p $GITHUB_WORKSPACE/node_modules + cp -r /tmp/compressors/node_modules/* $GITHUB_WORKSPACE/node_modules/ + + - name: Create test files + run: | + mkdir -p src + cat > src/app.js << 'EOF' + function hello() { console.log("hello world"); } + EOF + + cat > src/utils.js << 'EOF' + export const add = (a, b) => a + b; + EOF + + cat > src/styles.css << 'EOF' + body { margin: 0; padding: 0; } + EOF + + - name: Run auto mode + uses: ./ + with: + auto: "true" + output-dir: "dist" + + - name: Verify outputs + run: | + test -f dist/src/app.js || (echo "dist/src/app.js not found" && exit 1) + test -f dist/src/utils.js || (echo "dist/src/utils.js not found" && exit 1) + test -f dist/src/styles.css || (echo "dist/src/styles.css not found" && exit 1) + echo "All output files created successfully" + + test-zero-config-dry-run: + name: Test Zero Config Dry Run + runs-on: ubuntu-latest + needs: build-action + steps: + - uses: actions/checkout@v4 + + - name: Download built action + uses: actions/download-artifact@v4 + with: + name: action-dist + path: packages/action/dist/ + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: "20" + + - name: Install compressor + run: | + mkdir -p /tmp/compressors && cd /tmp/compressors + npm init -y + npm install @node-minify/terser + mkdir -p $GITHUB_WORKSPACE/node_modules + cp -r /tmp/compressors/node_modules/* $GITHUB_WORKSPACE/node_modules/ + + - name: Create test file + run: | + mkdir -p src + cat > src/app.js << 'EOF' + function test() { return 42; } + EOF + + - name: Run dry-run mode + uses: ./ + with: + auto: "true" + dry-run: "true" + output-dir: "dist" + + - name: Verify no output created + run: | + test ! -d dist || (echo "dist/ should not exist in dry-run" && exit 1) + echo "Dry-run passed - no output created" + + test-zero-config-custom-patterns: + name: Test Zero Config Custom Patterns + runs-on: ubuntu-latest + needs: build-action + steps: + - uses: actions/checkout@v4 + + - name: Download built action + uses: actions/download-artifact@v4 + with: + name: action-dist + path: packages/action/dist/ + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: "20" + + - name: Install compressor + run: | + mkdir -p /tmp/compressors && cd /tmp/compressors + npm init -y + npm install @node-minify/terser + mkdir -p $GITHUB_WORKSPACE/node_modules + cp -r /tmp/compressors/node_modules/* $GITHUB_WORKSPACE/node_modules/ + + - name: Create files in custom directory + run: | + mkdir -p custom/path + cat > custom/path/main.js << 'EOF' + console.log("custom location"); + EOF + + - name: Run with custom patterns + uses: ./ + with: + auto: "true" + patterns: "custom/**/*.js" + output-dir: "dist" + + - name: Verify custom output + run: | + test -f dist/custom/path/main.js || (echo "dist/custom/path/main.js not found" && exit 1) + echo "Custom patterns worked correctly" + test-summary: name: Summary runs-on: ubuntu-latest - needs: [test-js-minification, test-css-minification] + needs: + [ + test-js-minification, + test-css-minification, + test-zero-config, + test-zero-config-dry-run, + test-zero-config-custom-patterns, + ] steps: - name: All tests passed run: echo "All node-minify action tests passed!" diff --git a/.gitignore b/.gitignore index 4c44de22f..f91d91e20 100644 --- a/.gitignore +++ b/.gitignore @@ -16,3 +16,5 @@ examples/public/**/*-dist examples/public/**/*.min.html packages/utils/__tests__/temp_perf/ *.tmp +.sisyphus/ +.claude/ diff --git a/action.yml b/action.yml index f451a9f04..e6e7b7924 100644 --- a/action.yml +++ b/action.yml @@ -8,10 +8,28 @@ branding: inputs: input: description: "Files to minify (glob pattern or path)" - required: true + required: false output: description: "Output file path" - required: true + required: false + auto: + description: "Enable zero-config auto mode" + required: false + default: "false" + patterns: + description: "Custom glob patterns for auto mode (comma-separated)" + required: false + output-dir: + description: "Output directory for auto mode" + required: false + default: "dist" + ignore: + description: "Additional ignore patterns for auto mode (comma-separated)" + required: false + dry-run: + description: "Preview mode - show what would be processed without minifying" + required: false + default: "false" compressor: description: | Compressor to use. diff --git a/docs/src/content/docs/github-action.md b/docs/src/content/docs/github-action.md index 1d1d3a5e5..c18672d98 100644 --- a/docs/src/content/docs/github-action.md +++ b/docs/src/content/docs/github-action.md @@ -15,6 +15,140 @@ Minify JavaScript, CSS, and HTML files directly in your GitHub workflows with de - 🎯 **Thresholds** - Fail builds on size regressions - 🏁 **Benchmark** - Compare compressor performance +## Zero-Config Mode + +Enable automatic file discovery and compressor selection with `auto: true`. The action scans standard directories (`src/`, `app/`, `lib/`, `styles/`), detects file types, and applies appropriate compressors automatically. + +### How It Works + +1. **File Discovery**: Scans project for JS, CSS, HTML, JSON, and SVG files +2. **Type Detection**: Determines file type from extension +3. **Compressor Selection**: Chooses optimal compressor per file type +4. **Parallel Processing**: Minifies files concurrently with concurrency limit +5. **Grouped Results**: Summary organized by file type + +### Default Behavior + +**Included Directories**: +- `src/**/*.{js,mjs,cjs,jsx,css,html,htm,json,svg}` +- `app/**/*.{js,mjs,cjs,jsx,css,html,htm,json,svg}` +- `lib/**/*.{js,mjs,cjs,jsx,css,html,htm,json,svg}` +- `styles/**/*.{css}` +- Root-level `*.{js,css,html,json,svg}` + +**Excluded By Default**: +- `**/node_modules/**` +- `**/dist/**`, `**/build/**`, `**/.next/**` +- `**/*.min.{js,css}` (already minified) +- `**/__tests__/**` (test files) +- `**/.*` (hidden files) +- `**/*.d.ts` (TypeScript declarations) + +**Note**: TypeScript files (`.ts`, `.tsx`) are excluded. Minifiers operate on compiled JavaScript, not TypeScript source. Compile first, then minify. + +### Required Compressor Packages + +| File Type | Default Compressor | Required Package | +|-----------|-------------------|------------------| +| JavaScript (`.js`, `.mjs`, `.cjs`, `.jsx`) | terser | `@node-minify/terser` | +| CSS (`.css`) | lightningcss | `@node-minify/lightningcss` | +| HTML (`.html`, `.htm`) | html-minifier | `@node-minify/html-minifier` | +| JSON (`.json`) | jsonminify | `@node-minify/jsonminify` | +| SVG (`.svg`) | svgo | `@node-minify/svgo` | + +**Install only the compressors you need** based on your project's file types. + +### Basic Usage + +```yaml +- name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + +# Install compressors for your file types +- name: Install compressors + run: | + npm install @node-minify/terser @node-minify/lightningcss + +- name: Minify all files + uses: srod/node-minify@v1 + with: + auto: 'true' +``` + +Output files preserve directory structure in `dist/`: +- `src/app.js` → `dist/src/app.js` +- `src/styles.css` → `dist/src/styles.css` + +### Custom Patterns + +Override default patterns to target specific files: + +```yaml +- name: Minify custom locations + uses: srod/node-minify@v1 + with: + auto: 'true' + patterns: 'public/**/*.js,assets/**/*.css' + output-dir: 'build' +``` + +### Custom Ignore Patterns + +Add additional ignore patterns (merges with defaults): + +```yaml +- name: Minify with custom ignores + uses: srod/node-minify@v1 + with: + auto: 'true' + ignore: '**/*.config.js,**/vendor/**' +``` + +### Dry-Run Mode + +Preview which files would be processed without minifying: + +```yaml +- name: Preview auto mode + uses: srod/node-minify@v1 + with: + auto: 'true' + dry-run: 'true' +``` + +Check the job summary or logs to see discovered files. + +### Inputs + +| Input | Description | Required | Default | +|-------|-------------|----------|---------| +| `auto` | Enable zero-config mode | No | `false` | +| `patterns` | Custom glob patterns (comma-separated) | No | Standard directories | +| `output-dir` | Output directory | No | `dist` | +| `ignore` | Additional ignore patterns (comma-separated) | No | - | +| `dry-run` | Preview mode - discover files without processing | No | `false` | + +### Example: Mixed Project + +For a project with JavaScript, CSS, and HTML: + +```yaml +- name: Install all compressors + run: | + npm install \ + @node-minify/terser \ + @node-minify/lightningcss \ + @node-minify/html-minifier + +- name: Minify all assets + uses: srod/node-minify@v1 + with: + auto: 'true' + output-dir: 'public/dist' +``` + ## Quick Start ```yaml diff --git a/packages/action/README.md b/packages/action/README.md index 711c79eb4..5386c4868 100644 --- a/packages/action/README.md +++ b/packages/action/README.md @@ -45,6 +45,25 @@ Compressor packages contain native dependencies that cannot be bundled into the compressor: "terser" ``` +### Zero-Config Mode (Auto Discovery) + +Automatically discover and minify files with `auto: true`: + +```yaml +- name: Install compressors + # Add other compressors (html-minifier, jsonminify, svgo) if needed + run: npm install @node-minify/terser @node-minify/lightningcss + +- name: Minify all files + uses: srod/node-minify@v1 + with: + auto: 'true' +``` + +Scans `src/`, `app/`, `lib/`, `styles/` for JS/CSS/HTML/JSON/SVG files, applies appropriate compressors, and outputs to `dist/` with preserved structure. + +See [full zero-config documentation](https://node-minify.2clics.net/github-action#zero-config-mode) for patterns, customization, and dry-run mode. + ### With PR Comment ```yaml diff --git a/packages/action/__tests__/action.test.ts b/packages/action/__tests__/action.test.ts index b407c6ea7..b4523e4bb 100644 --- a/packages/action/__tests__/action.test.ts +++ b/packages/action/__tests__/action.test.ts @@ -20,6 +20,9 @@ describe("Action Types", () => { minReduction: 0, includeGzip: true, workingDirectory: ".", + auto: false, + outputDir: "dist", + dryRun: false, }; expect(inputs.input).toBe("src/app.js"); diff --git a/packages/action/__tests__/autoDetect.test.ts b/packages/action/__tests__/autoDetect.test.ts new file mode 100644 index 000000000..06751caad --- /dev/null +++ b/packages/action/__tests__/autoDetect.test.ts @@ -0,0 +1,256 @@ +/*! + * node-minify + * Copyright (c) 2011-2026 Rodolphe Stoclin + * MIT Licensed + */ + +import { describe, expect, test } from "vitest"; +import { + type CompressorSelection, + detectFileType, + type FileType, + groupFilesByType, + selectCompressor, +} from "../src/autoDetect.ts"; + +describe("autoDetect", () => { + describe("detectFileType", () => { + test("detects .js files as js", () => { + expect(detectFileType("app.js")).toBe("js"); + }); + + test("detects .jsx files as js", () => { + expect(detectFileType("Component.jsx")).toBe("js"); + }); + + test("detects .mjs files as js", () => { + expect(detectFileType("module.mjs")).toBe("js"); + }); + + test("detects .cjs files as js", () => { + expect(detectFileType("config.cjs")).toBe("js"); + }); + + test("detects .css files as css", () => { + expect(detectFileType("styles.css")).toBe("css"); + }); + + test("detects .html files as html", () => { + expect(detectFileType("index.html")).toBe("html"); + }); + + test("detects .htm files as html", () => { + expect(detectFileType("page.htm")).toBe("html"); + }); + + test("detects .json files as json", () => { + expect(detectFileType("data.json")).toBe("json"); + }); + + test("detects .svg files as svg", () => { + expect(detectFileType("logo.svg")).toBe("svg"); + }); + + test("detects unknown extensions as unknown", () => { + expect(detectFileType("file.xyz")).toBe("unknown"); + }); + + test("excludes .ts files (returns unknown)", () => { + expect(detectFileType("app.ts")).toBe("unknown"); + }); + + test("excludes .tsx files (returns unknown)", () => { + expect(detectFileType("Component.tsx")).toBe("unknown"); + }); + + test("excludes .mts files (returns unknown)", () => { + expect(detectFileType("module.mts")).toBe("unknown"); + }); + + test("excludes .cts files (returns unknown)", () => { + expect(detectFileType("config.cts")).toBe("unknown"); + }); + + test("handles case-insensitive extensions", () => { + expect(detectFileType("APP.JS")).toBe("js"); + expect(detectFileType("STYLES.CSS")).toBe("css"); + }); + + test("handles paths with directories", () => { + expect(detectFileType("src/components/Button.jsx")).toBe("js"); + expect(detectFileType("/absolute/path/to/styles.css")).toBe("css"); + }); + }); + + describe("selectCompressor", () => { + test("selects terser for js files", () => { + const result = selectCompressor("js"); + expect(result.compressor).toBe("terser"); + expect(result.package).toBe("@node-minify/terser"); + expect(result.type).toBeUndefined(); + }); + + test("selects lightningcss for css files", () => { + const result = selectCompressor("css"); + expect(result.compressor).toBe("lightningcss"); + expect(result.package).toBe("@node-minify/lightningcss"); + expect(result.type).toBeUndefined(); + }); + + test("selects html-minifier for html files", () => { + const result = selectCompressor("html"); + expect(result.compressor).toBe("html-minifier"); + expect(result.package).toBe("@node-minify/html-minifier"); + expect(result.type).toBeUndefined(); + }); + + test("selects jsonminify for json files", () => { + const result = selectCompressor("json"); + expect(result.compressor).toBe("jsonminify"); + expect(result.package).toBe("@node-minify/jsonminify"); + expect(result.type).toBeUndefined(); + }); + + test("selects svgo for svg files", () => { + const result = selectCompressor("svg"); + expect(result.compressor).toBe("svgo"); + expect(result.package).toBe("@node-minify/svgo"); + expect(result.type).toBeUndefined(); + }); + + test("selects no-compress for unknown files", () => { + const result = selectCompressor("unknown"); + expect(result.compressor).toBe("no-compress"); + expect(result.package).toBe("@node-minify/no-compress"); + expect(result.type).toBeUndefined(); + }); + + test("returns CompressorSelection with correct structure", () => { + const result: CompressorSelection = selectCompressor("js"); + expect(result).toHaveProperty("compressor"); + expect(result).toHaveProperty("package"); + }); + }); + + describe("groupFilesByType", () => { + test("groups files by detected type", () => { + const files = ["a.js", "b.css", "c.js", "d.html"]; + const groups = groupFilesByType(files); + + expect(groups.js).toEqual(["a.js", "c.js"]); + expect(groups.css).toEqual(["b.css"]); + expect(groups.html).toEqual(["d.html"]); + expect(groups.json).toEqual([]); + expect(groups.svg).toEqual([]); + expect(groups.unknown).toEqual([]); + }); + + test("handles mixed file types", () => { + const files = [ + "app.js", + "styles.css", + "index.html", + "data.json", + "logo.svg", + "file.xyz", + ]; + const groups = groupFilesByType(files); + + expect(groups.js).toEqual(["app.js"]); + expect(groups.css).toEqual(["styles.css"]); + expect(groups.html).toEqual(["index.html"]); + expect(groups.json).toEqual(["data.json"]); + expect(groups.svg).toEqual(["logo.svg"]); + expect(groups.unknown).toEqual(["file.xyz"]); + }); + + test("handles empty array", () => { + const groups = groupFilesByType([]); + + expect(groups.js).toEqual([]); + expect(groups.css).toEqual([]); + expect(groups.html).toEqual([]); + expect(groups.json).toEqual([]); + expect(groups.svg).toEqual([]); + expect(groups.unknown).toEqual([]); + }); + + test("handles all files of same type", () => { + const files = ["a.js", "b.js", "c.js"]; + const groups = groupFilesByType(files); + + expect(groups.js).toEqual(["a.js", "b.js", "c.js"]); + expect(groups.css).toEqual([]); + }); + + test("excludes TypeScript files (groups as unknown)", () => { + const files = ["app.ts", "Component.tsx", "module.mts"]; + const groups = groupFilesByType(files); + + expect(groups.js).toEqual([]); + expect(groups.unknown).toEqual([ + "app.ts", + "Component.tsx", + "module.mts", + ]); + }); + + test("preserves file order within groups", () => { + const files = ["z.js", "a.js", "m.js"]; + const groups = groupFilesByType(files); + + expect(groups.js).toEqual(["z.js", "a.js", "m.js"]); + }); + + test("handles paths with directories", () => { + const files = [ + "src/app.js", + "dist/styles.css", + "public/index.html", + ]; + const groups = groupFilesByType(files); + + expect(groups.js).toEqual(["src/app.js"]); + expect(groups.css).toEqual(["dist/styles.css"]); + expect(groups.html).toEqual(["public/index.html"]); + }); + }); + + describe("type exports", () => { + test("FileType includes all expected values", () => { + const validTypes: FileType[] = [ + "js", + "css", + "html", + "json", + "svg", + "unknown", + ]; + expect(validTypes).toHaveLength(6); + }); + + test("CompressorSelection has required properties", () => { + const selection: CompressorSelection = { + compressor: "terser", + package: "@node-minify/terser", + }; + expect(selection.compressor).toBe("terser"); + expect(selection.package).toBe("@node-minify/terser"); + }); + + test("CompressorSelection type property is optional", () => { + const withType: CompressorSelection = { + compressor: "esbuild", + type: "js", + package: "@node-minify/esbuild", + }; + expect(withType.type).toBe("js"); + + const withoutType: CompressorSelection = { + compressor: "terser", + package: "@node-minify/terser", + }; + expect(withoutType.type).toBeUndefined(); + }); + }); +}); diff --git a/packages/action/__tests__/discover.test.ts b/packages/action/__tests__/discover.test.ts new file mode 100644 index 000000000..726f54f67 --- /dev/null +++ b/packages/action/__tests__/discover.test.ts @@ -0,0 +1,221 @@ +import path from "node:path"; +import * as core from "@actions/core"; +import fg from "fast-glob"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import { + DEFAULT_IGNORES, + DEFAULT_PATTERNS, + discoverFiles, + generateOutputPath, +} from "../src/discover.ts"; + +// Mock fast-glob globally +vi.mock("fast-glob", () => { + return { + default: { + globSync: vi.fn(), + }, + }; +}); + +// Mock @actions/core +vi.mock("@actions/core", () => ({ + info: vi.fn(), + warning: vi.fn(), +})); + +describe("discover", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe("DEFAULT_PATTERNS", () => { + test("should include common source directories without TypeScript", () => { + expect(DEFAULT_PATTERNS).toContain( + "src/**/*.{js,mjs,cjs,jsx,css,html,htm,json,svg}" + ); + expect(DEFAULT_PATTERNS).toContain( + "app/**/*.{js,mjs,cjs,jsx,css,html,htm,json,svg}" + ); + expect(DEFAULT_PATTERNS).toContain( + "lib/**/*.{js,mjs,cjs,jsx,css,html,htm,json,svg}" + ); + expect(DEFAULT_PATTERNS).toContain("styles/**/*.css"); + expect(DEFAULT_PATTERNS).toContain("*.{js,mjs,cjs,css,html,htm}"); + + const hasTypeScript = DEFAULT_PATTERNS.some( + (pattern: string) => + pattern.includes(".ts") || pattern.includes(".tsx") + ); + expect(hasTypeScript).toBe(false); + }); + }); + + describe("DEFAULT_IGNORES", () => { + test("should include standard ignore patterns", () => { + expect(DEFAULT_IGNORES).toContain("**/node_modules/**"); + expect(DEFAULT_IGNORES).toContain("**/dist/**"); + expect(DEFAULT_IGNORES).toContain("**/build/**"); + expect(DEFAULT_IGNORES).toContain("**/.next/**"); + expect(DEFAULT_IGNORES).toContain("**/*.min.{js,css}"); + expect(DEFAULT_IGNORES).toContain("**/*.d.ts"); + expect(DEFAULT_IGNORES).toContain("**/__tests__/**"); + expect(DEFAULT_IGNORES).toContain("**/.*"); + }); + }); + + describe("discoverFiles", () => { + test("should find files using default patterns in common directories", () => { + vi.mocked(fg.globSync).mockReturnValue([ + "src/app.js", + "app/main.js", + "lib/utils.js", + "styles/main.css", + ]); + + const files = discoverFiles(); + + expect(fg.globSync).toHaveBeenCalledWith(DEFAULT_PATTERNS, { + cwd: process.cwd(), + ignore: DEFAULT_IGNORES, + followSymbolicLinks: false, + onlyFiles: true, + }); + expect(files).toEqual([ + "src/app.js", + "app/main.js", + "lib/utils.js", + "styles/main.css", + ]); + }); + + test("should exclude files matching default ignores", () => { + vi.mocked(fg.globSync).mockReturnValue([ + "src/app.js", + "lib/utils.js", + ]); + + const files = discoverFiles(); + + expect(fg.globSync).toHaveBeenCalledWith( + expect.any(Array), + expect.objectContaining({ + ignore: expect.arrayContaining([ + "**/node_modules/**", + "**/dist/**", + "**/.next/**", + ]), + }) + ); + expect(files).toEqual(["src/app.js", "lib/utils.js"]); + }); + + test("should use custom patterns when provided", () => { + vi.mocked(fg.globSync).mockReturnValue(["custom/file.js"]); + + const files = discoverFiles({ + patterns: ["custom/**/*.js"], + }); + + expect(fg.globSync).toHaveBeenCalledWith(["custom/**/*.js"], { + cwd: process.cwd(), + ignore: DEFAULT_IGNORES, + followSymbolicLinks: false, + onlyFiles: true, + }); + expect(files).toEqual(["custom/file.js"]); + }); + + test("should merge custom ignore with defaults", () => { + vi.mocked(fg.globSync).mockReturnValue(["src/app.js"]); + + discoverFiles({ + ignore: ["**/temp/**", "**/cache/**"], + }); + + expect(fg.globSync).toHaveBeenCalledWith( + expect.any(Array), + expect.objectContaining({ + ignore: [...DEFAULT_IGNORES, "**/temp/**", "**/cache/**"], + }) + ); + }); + + test("should log files in dry-run mode", () => { + vi.mocked(fg.globSync).mockReturnValue([ + "src/app.js", + "src/utils.js", + ]); + + const files = discoverFiles({ dryRun: true }); + + expect(core.info).toHaveBeenCalledWith( + "[dry-run] Would process 2 files" + ); + expect(core.info).toHaveBeenCalledWith(" - src/app.js"); + expect(core.info).toHaveBeenCalledWith(" - src/utils.js"); + expect(files).toEqual(["src/app.js", "src/utils.js"]); + }); + + test("should warn when no files found", () => { + vi.mocked(fg.globSync).mockReturnValue([]); + + const files = discoverFiles(); + + expect(core.warning).toHaveBeenCalledWith( + "No files found matching patterns" + ); + expect(files).toEqual([]); + }); + + test("should use custom working directory", () => { + vi.mocked(fg.globSync).mockReturnValue(["src/app.js"]); + + discoverFiles({ workingDirectory: "/custom/path" }); + + expect(fg.globSync).toHaveBeenCalledWith( + expect.any(Array), + expect.objectContaining({ + cwd: "/custom/path", + }) + ); + }); + }); + + describe("generateOutputPath", () => { + test("should join input file with output directory", () => { + const result = generateOutputPath("src/app.js", "dist"); + expect(result).toBe(path.join("dist", "src/app.js")); + }); + + test("should handle absolute input paths by stripping root", () => { + const result = generateOutputPath("/var/log/test.log", "dist"); + expect(result).toBe(path.join("dist", "var/log/test.log")); + }); + + test("should sanitize directory traversal in input path", () => { + const result = generateOutputPath("../../test.log", "dist"); + expect(result).toBe(path.join("dist", "test.log")); + }); + + test("should sanitize nested directory traversal", () => { + const result = generateOutputPath("foo/../../bar.js", "dist"); + expect(result).toBe(path.join("dist", "bar.js")); + }); + + test("should preserve nested directory structure", () => { + const result = generateOutputPath( + "app/components/Button.js", + "output" + ); + expect(result).toBe( + path.join("output", "app/components/Button.js") + ); + }); + + test("should handle root-level files", () => { + const result = generateOutputPath("main.js", "build"); + expect(result).toBe(path.join("build", "main.js")); + }); + }); +}); diff --git a/packages/action/__tests__/index.test.ts b/packages/action/__tests__/index.test.ts new file mode 100644 index 000000000..ba2e914d6 --- /dev/null +++ b/packages/action/__tests__/index.test.ts @@ -0,0 +1,121 @@ +/*! node-minify action tests - MIT Licensed */ + +import { setFailed } from "@actions/core"; +import { beforeEach, describe, expect, test, vi } from "vitest"; +import { _internal, chunkArray, run } from "../src/index.ts"; +import { parseInputs } from "../src/inputs.ts"; + +vi.mock("@actions/core"); +vi.mock("../src/inputs.ts"); + +describe("chunkArray", () => { + test("splits array into chunks of specified size", () => { + const result = chunkArray([1, 2, 3, 4, 5, 6], 2); + expect(result).toEqual([ + [1, 2], + [3, 4], + [5, 6], + ]); + }); + + test("handles array not evenly divisible by chunk size", () => { + const result = chunkArray([1, 2, 3, 4, 5], 2); + expect(result).toEqual([[1, 2], [3, 4], [5]]); + }); + + test("handles empty array", () => { + const result = chunkArray([], 3); + expect(result).toEqual([]); + }); + + test("handles chunk size larger than array", () => { + const result = chunkArray([1, 2], 5); + expect(result).toEqual([[1, 2]]); + }); + + test("handles chunk size of 1", () => { + const result = chunkArray([1, 2, 3], 1); + expect(result).toEqual([[1], [2], [3]]); + }); + + test("preserves type with generic", () => { + const result = chunkArray(["a", "b", "c", "d"], 2); + expect(result).toEqual([ + ["a", "b"], + ["c", "d"], + ]); + }); +}); + +describe("run", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.spyOn(_internal, "runAutoMode").mockImplementation(async () => {}); + vi.spyOn(_internal, "runExplicitMode").mockImplementation( + async () => {} + ); + }); + + test("calls runAutoMode when auto is true", async () => { + vi.mocked(parseInputs).mockReturnValue({ auto: true } as any); + await run(); + expect(_internal.runAutoMode).toHaveBeenCalledWith({ auto: true }); + expect(_internal.runExplicitMode).not.toHaveBeenCalled(); + expect(setFailed).not.toHaveBeenCalled(); + }); + + test("calls runExplicitMode when auto is false", async () => { + vi.mocked(parseInputs).mockReturnValue({ auto: false } as any); + await run(); + expect(_internal.runExplicitMode).toHaveBeenCalledWith({ auto: false }); + expect(_internal.runAutoMode).not.toHaveBeenCalled(); + expect(setFailed).not.toHaveBeenCalled(); + }); + + test("calls setFailed when parseInputs throws Error", async () => { + const error = new Error("Parse error"); + vi.mocked(parseInputs).mockImplementation(() => { + throw error; + }); + await run(); + expect(setFailed).toHaveBeenCalledWith("Parse error"); + }); + + test("calls setFailed with generic message when parseInputs throws unknown error", async () => { + vi.mocked(parseInputs).mockImplementation(() => { + throw "Something went wrong"; + }); + await run(); + expect(setFailed).toHaveBeenCalledWith("An unknown error occurred"); + }); + + test("calls setFailed when runAutoMode fails", async () => { + vi.mocked(parseInputs).mockReturnValue({ auto: true } as any); + const error = new Error("Auto mode failed"); + vi.mocked(_internal.runAutoMode).mockRejectedValue(error); + await run(); + expect(setFailed).toHaveBeenCalledWith("Auto mode failed"); + }); + + test("calls setFailed when runExplicitMode fails", async () => { + vi.mocked(parseInputs).mockReturnValue({ auto: false } as any); + const error = new Error("Explicit mode failed"); + vi.mocked(_internal.runExplicitMode).mockRejectedValue(error); + await run(); + expect(setFailed).toHaveBeenCalledWith("Explicit mode failed"); + }); + + test("no error on success (auto mode)", async () => { + vi.mocked(parseInputs).mockReturnValue({ auto: true } as any); + vi.mocked(_internal.runAutoMode).mockResolvedValue(undefined); + await run(); + expect(setFailed).not.toHaveBeenCalled(); + }); + + test("no error on success (explicit mode)", async () => { + vi.mocked(parseInputs).mockReturnValue({ auto: false } as any); + vi.mocked(_internal.runExplicitMode).mockResolvedValue(undefined); + await run(); + expect(setFailed).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/action/__tests__/inputs.test.ts b/packages/action/__tests__/inputs.test.ts index b4b16ffe4..55a5f8ae8 100644 --- a/packages/action/__tests__/inputs.test.ts +++ b/packages/action/__tests__/inputs.test.ts @@ -265,3 +265,145 @@ describe("parseInputs edge cases", () => { } }); }); + +describe("parseInputs auto mode", () => { + beforeEach(() => { + vi.resetAllMocks(); + vi.mocked(getBooleanInput).mockImplementation((name: string) => { + const defaults: Record = { + "report-summary": true, + "report-pr-comment": false, + "report-annotations": false, + benchmark: false, + "fail-on-increase": false, + "include-gzip": true, + auto: false, + "dry-run": false, + }; + return defaults[name] ?? false; + }); + }); + + test("throws error when auto=false without input/output", () => { + vi.mocked(getInput).mockImplementation((name: string) => { + if (name === "input") return ""; + if (name === "output") return ""; + return ""; + }); + vi.mocked(getBooleanInput).mockImplementation((name: string) => { + if (name === "auto") return false; + return false; + }); + + expect(() => parseInputs()).toThrow( + "Explicit mode requires both 'input' and 'output'" + ); + }); + + test("does not throw when auto=true without input/output", () => { + vi.mocked(getInput).mockImplementation((name: string) => { + if (name === "input") return ""; + if (name === "output") return ""; + if (name === "output-dir") return "dist"; + return ""; + }); + vi.mocked(getBooleanInput).mockImplementation((name: string) => { + if (name === "auto") return true; + return false; + }); + + expect(() => parseInputs()).not.toThrow(); + }); + + test("parses patterns from comma-separated string", () => { + vi.mocked(getInput).mockImplementation((name: string) => { + if (name === "patterns") return "src/**/*.js, lib/**/*.ts"; + if (name === "output-dir") return "dist"; + return ""; + }); + vi.mocked(getBooleanInput).mockImplementation((name: string) => { + if (name === "auto") return true; + return false; + }); + + const inputs = parseInputs(); + expect(inputs.patterns).toEqual(["src/**/*.js", "lib/**/*.ts"]); + }); + + test("parses ignore from comma-separated string", () => { + vi.mocked(getInput).mockImplementation((name: string) => { + if (name === "ignore") return "**/*.test.js, **/*.spec.ts"; + if (name === "output-dir") return "dist"; + return ""; + }); + vi.mocked(getBooleanInput).mockImplementation((name: string) => { + if (name === "auto") return true; + return false; + }); + + const inputs = parseInputs(); + expect(inputs.additionalIgnore).toEqual([ + "**/*.test.js", + "**/*.spec.ts", + ]); + }); + + test("outputDir defaults to 'dist'", () => { + vi.mocked(getInput).mockImplementation((name: string) => { + if (name === "output-dir") return ""; + return ""; + }); + vi.mocked(getBooleanInput).mockImplementation((name: string) => { + if (name === "auto") return true; + return false; + }); + + const inputs = parseInputs(); + expect(inputs.outputDir).toBe("dist"); + }); + + test("dryRun defaults to false", () => { + vi.mocked(getInput).mockImplementation(() => ""); + vi.mocked(getBooleanInput).mockImplementation((name: string) => { + if (name === "auto") return true; + if (name === "dry-run") return false; + return false; + }); + + const inputs = parseInputs(); + expect(inputs.dryRun).toBe(false); + }); + + test("filters empty strings from patterns", () => { + vi.mocked(getInput).mockImplementation((name: string) => { + if (name === "patterns") return "src/**/*.js, , lib/**/*.ts, "; + if (name === "output-dir") return "dist"; + return ""; + }); + vi.mocked(getBooleanInput).mockImplementation((name: string) => { + if (name === "auto") return true; + return false; + }); + + const inputs = parseInputs(); + expect(inputs.patterns).toEqual(["src/**/*.js", "lib/**/*.ts"]); + }); + + test("filters empty strings from ignore", () => { + vi.mocked(getInput).mockImplementation((name: string) => { + if (name === "ignore") return "**/*.test.js, , **/*.spec.ts, "; + if (name === "output-dir") return "dist"; + return ""; + }); + vi.mocked(getBooleanInput).mockImplementation((name: string) => { + if (name === "auto") return true; + return false; + }); + + const inputs = parseInputs(); + expect(inputs.additionalIgnore).toEqual([ + "**/*.test.js", + "**/*.spec.ts", + ]); + }); +}); diff --git a/packages/action/__tests__/minify.test.ts b/packages/action/__tests__/minify.test.ts new file mode 100644 index 000000000..242dd4d4b --- /dev/null +++ b/packages/action/__tests__/minify.test.ts @@ -0,0 +1,174 @@ +/*! node-minify action tests - MIT Licensed */ + +import { stat } from "node:fs/promises"; +import { minify } from "@node-minify/core"; +import { getFilesizeGzippedRaw, resolveCompressor } from "@node-minify/utils"; +import { beforeEach, describe, expect, test, vi } from "vitest"; +import { runMinification } from "../src/minify.ts"; + +vi.mock("node:fs/promises"); +vi.mock("@node-minify/core"); +vi.mock("@node-minify/utils"); + +describe("runMinification", () => { + const mockInputs = { + input: "src/app.js", + output: "dist/app.min.js", + compressor: "terser", + type: undefined, + options: {}, + reportSummary: true, + reportPRComment: false, + reportAnnotations: false, + benchmark: false, + benchmarkCompressors: [], + failOnIncrease: false, + minReduction: 0, + includeGzip: true, + workingDirectory: ".", + auto: false, + outputDir: "dist", + dryRun: false, + }; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + test("should throw error if input is missing", async () => { + const inputs = { ...mockInputs, input: "" }; + await expect(runMinification(inputs)).rejects.toThrow( + "Input and output files are required for explicit mode" + ); + }); + + test("should throw error if output is missing", async () => { + const inputs = { ...mockInputs, output: "" }; + await expect(runMinification(inputs)).rejects.toThrow( + "Input and output files are required for explicit mode" + ); + }); + + test("should perform basic minification and calculate sizes", async () => { + vi.mocked(stat) + .mockResolvedValueOnce({ size: 1000 } as any) + .mockResolvedValueOnce({ size: 500 } as any); + vi.mocked(resolveCompressor).mockResolvedValue({ + compressor: vi.fn(), + label: "terser", + } as any); + vi.mocked(minify).mockResolvedValue("minified content"); + vi.mocked(getFilesizeGzippedRaw).mockResolvedValue(300); + + const result = await runMinification(mockInputs); + + expect(result).toEqual({ + files: [ + { + file: "src/app.js", + originalSize: 1000, + minifiedSize: 500, + reduction: 50, + gzipSize: 300, + timeMs: expect.any(Number), + }, + ], + compressor: "terser", + totalOriginalSize: 1000, + totalMinifiedSize: 500, + totalReduction: 50, + totalTimeMs: expect.any(Number), + }); + }); + + test("should include gzip size when includeGzip is true", async () => { + vi.mocked(stat) + .mockResolvedValueOnce({ size: 1000 } as any) + .mockResolvedValueOnce({ size: 500 } as any); + vi.mocked(resolveCompressor).mockResolvedValue({ + compressor: vi.fn(), + label: "terser", + } as any); + vi.mocked(getFilesizeGzippedRaw).mockResolvedValue(300); + + const result = await runMinification({ + ...mockInputs, + includeGzip: true, + }); + const fileResult = result.files[0]; + expect(fileResult).toBeDefined(); + expect(fileResult?.gzipSize).toBe(300); + expect(getFilesizeGzippedRaw).toHaveBeenCalled(); + }); + + test("should skip gzip size when includeGzip is false", async () => { + vi.mocked(stat) + .mockResolvedValueOnce({ size: 1000 } as any) + .mockResolvedValueOnce({ size: 500 } as any); + vi.mocked(resolveCompressor).mockResolvedValue({ + compressor: vi.fn(), + label: "terser", + } as any); + + const result = await runMinification({ + ...mockInputs, + includeGzip: false, + }); + const fileResult = result.files[0]; + expect(fileResult).toBeDefined(); + expect(fileResult?.gzipSize).toBeUndefined(); + expect(getFilesizeGzippedRaw).not.toHaveBeenCalled(); + }); + + test("should calculate zero reduction when sizes are equal", async () => { + vi.mocked(stat) + .mockResolvedValueOnce({ size: 1000 } as any) + .mockResolvedValueOnce({ size: 1000 } as any); + vi.mocked(resolveCompressor).mockResolvedValue({ + compressor: vi.fn(), + label: "terser", + } as any); + + const result = await runMinification(mockInputs); + expect(result.totalReduction).toBe(0); + }); + + test("should handle zero original size", async () => { + vi.mocked(stat) + .mockResolvedValueOnce({ size: 0 } as any) + .mockResolvedValueOnce({ size: 0 } as any); + vi.mocked(resolveCompressor).mockResolvedValue({ + compressor: vi.fn(), + label: "terser", + } as any); + + const result = await runMinification(mockInputs); + expect(result.totalReduction).toBe(0); + }); + + test("should pass type and options to minify", async () => { + vi.mocked(stat).mockResolvedValue({ size: 100 } as any); + const mockComp = vi.fn(); + vi.mocked(resolveCompressor).mockResolvedValue({ + compressor: mockComp, + label: "esbuild", + } as any); + + const inputs = { + ...mockInputs, + compressor: "esbuild", + type: "js" as const, + options: { minify: true }, + }; + + await runMinification(inputs); + + expect(minify).toHaveBeenCalledWith( + expect.objectContaining({ + compressor: mockComp, + type: "js", + options: { minify: true }, + }) + ); + }); +}); diff --git a/packages/action/__tests__/reporters/summary.test.ts b/packages/action/__tests__/reporters/summary.test.ts new file mode 100644 index 000000000..130946ee8 --- /dev/null +++ b/packages/action/__tests__/reporters/summary.test.ts @@ -0,0 +1,131 @@ +/*! node-minify summary reporter tests - MIT Licensed */ + +import { summary } from "@actions/core"; +import { describe, expect, test, vi } from "vitest"; +import { generateAutoModeSummary } from "../../src/reporters/summary.ts"; +import type { ActionInputs, MinifyResult } from "../../src/types.ts"; + +// Mock @actions/core +vi.mock("@actions/core", () => ({ + summary: { + addHeading: vi.fn().mockReturnThis(), + addTable: vi.fn().mockReturnThis(), + addBreak: vi.fn().mockReturnThis(), + addRaw: vi.fn().mockReturnThis(), + write: vi.fn().mockResolvedValue({}), + }, +})); + +describe("generateAutoModeSummary", () => { + const inputs: ActionInputs = { + auto: true, + outputDir: "dist", + compressor: "auto", + options: {}, + reportSummary: true, + reportPRComment: false, + reportAnnotations: false, + benchmark: false, + benchmarkCompressors: [], + failOnIncrease: false, + minReduction: 0, + includeGzip: true, + workingDirectory: ".", + dryRun: false, + }; + + test("groups results by file type", async () => { + const results: MinifyResult[] = [ + { + files: [ + { + file: "a.js", + originalSize: 100, + minifiedSize: 50, + reduction: 50, + timeMs: 10, + }, + ], + compressor: "terser", + totalOriginalSize: 100, + totalMinifiedSize: 50, + totalReduction: 50, + totalTimeMs: 10, + }, + { + files: [ + { + file: "b.css", + originalSize: 200, + minifiedSize: 100, + reduction: 50, + timeMs: 20, + }, + ], + compressor: "lightningcss", + totalOriginalSize: 200, + totalMinifiedSize: 100, + totalReduction: 50, + totalTimeMs: 20, + }, + ]; + + await generateAutoModeSummary(results, inputs); + + expect(summary.addHeading).toHaveBeenCalledWith("📜 JavaScript", 3); + expect(summary.addHeading).toHaveBeenCalledWith("🎨 CSS", 3); + expect(summary.addTable).toHaveBeenCalledTimes(2); + expect(summary.write).toHaveBeenCalled(); + }); + + test("calculates totals correctly across all types", async () => { + const results: MinifyResult[] = [ + { + files: [ + { + file: "a.js", + originalSize: 100, + minifiedSize: 50, + reduction: 50, + timeMs: 10, + }, + ], + compressor: "terser", + totalOriginalSize: 100, + totalMinifiedSize: 50, + totalReduction: 50, + totalTimeMs: 10, + }, + { + files: [ + { + file: "b.css", + originalSize: 200, + minifiedSize: 100, + reduction: 50, + timeMs: 20, + }, + ], + compressor: "lightningcss", + totalOriginalSize: 200, + totalMinifiedSize: 100, + totalReduction: 50, + totalTimeMs: 20, + }, + ]; + + await generateAutoModeSummary(results, inputs); + + expect(summary.addRaw).toHaveBeenCalledWith( + expect.stringContaining( + "**Total:** 300 B → 150 B (50.0% reduction)" + ) + ); + }); + + test("handles empty results gracefully", async () => { + const results: MinifyResult[] = []; + await generateAutoModeSummary(results, inputs); + expect(summary.addRaw).toHaveBeenCalledWith("No files were processed."); + }); +}); diff --git a/packages/action/__tests__/runAutoMode.test.ts b/packages/action/__tests__/runAutoMode.test.ts new file mode 100644 index 000000000..74346935b --- /dev/null +++ b/packages/action/__tests__/runAutoMode.test.ts @@ -0,0 +1,384 @@ +/*! node-minify action tests - MIT Licensed */ + +import { mkdir, stat } from "node:fs/promises"; +import path from "node:path"; +import * as core from "@actions/core"; +import { minify } from "@node-minify/core"; +import { getFilesizeGzippedRaw, resolveCompressor } from "@node-minify/utils"; +import { beforeEach, describe, expect, test, vi } from "vitest"; +import { groupFilesByType, selectCompressor } from "../src/autoDetect.ts"; +import { checkThresholds } from "../src/checks.ts"; +import { discoverFiles, generateOutputPath } from "../src/discover.ts"; +import { runAutoMode } from "../src/index.ts"; +import { setMinifyOutputs } from "../src/outputs.ts"; +import { generateSummary } from "../src/reporters/summary.ts"; + +vi.mock("@actions/core"); +vi.mock("@actions/github"); +vi.mock("node:fs/promises"); +vi.mock("../src/discover.ts"); +vi.mock("../src/autoDetect.ts"); +vi.mock("@node-minify/utils"); +vi.mock("@node-minify/core"); +vi.mock("../src/outputs.ts"); +vi.mock("../src/checks.ts"); +vi.mock("../src/reporters/summary.ts"); + +describe("runAutoMode", () => { + const mockInputs = { + auto: true, + patterns: ["**/*.js"], + outputDir: "dist", + additionalIgnore: [], + workingDirectory: ".", + dryRun: false, + reportSummary: true, + reportPRComment: false, + reportAnnotations: false, + failOnIncrease: false, + minReduction: 0, + includeGzip: true, + compressor: "terser", + input: "", + output: "", + type: undefined, + options: {}, + benchmark: false, + benchmarkCompressors: [], + githubToken: undefined, + }; + + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(stat).mockResolvedValue({ size: 100 } as any); + vi.mocked(resolveCompressor).mockResolvedValue({ + compressor: vi.fn(), + label: "terser", + } as any); + vi.mocked(selectCompressor).mockReturnValue({ + compressor: "terser", + package: "@node-minify/terser", + }); + vi.mocked(generateOutputPath).mockImplementation( + (file) => `min/${file}` + ); + vi.mocked(minify).mockResolvedValue("minified content"); + vi.mocked(checkThresholds).mockReturnValue(null); + vi.mocked(groupFilesByType).mockReturnValue({ + js: [], + css: [], + html: [], + json: [], + svg: [], + unknown: [], + }); + }); + + test("should set empty outputs when no files found", async () => { + vi.mocked(discoverFiles).mockReturnValue([]); + + await runAutoMode(mockInputs); + + expect(core.warning).toHaveBeenCalledWith( + "No files found matching patterns" + ); + expect(setMinifyOutputs).toHaveBeenCalledWith({ + files: [], + compressor: "auto", + totalOriginalSize: 0, + totalMinifiedSize: 0, + totalReduction: 0, + totalTimeMs: 0, + }); + }); + + test("should only log info in dry-run mode", async () => { + vi.mocked(discoverFiles).mockReturnValue(["file1.js", "file2.js"]); + const inputs = { ...mockInputs, dryRun: true }; + + await runAutoMode(inputs); + + expect(core.info).toHaveBeenCalledWith( + "[dry-run] Would process 2 files" + ); + expect(minify).not.toHaveBeenCalled(); + }); + + test("should throw error if compressor is not found", async () => { + vi.mocked(discoverFiles).mockReturnValue(["file1.js"]); + vi.mocked(groupFilesByType).mockReturnValue({ + js: ["file1.js"], + css: [], + html: [], + json: [], + svg: [], + unknown: [], + }); + vi.mocked(resolveCompressor).mockRejectedValueOnce( + new Error("Not found") + ); + + await expect(runAutoMode(mockInputs)).rejects.toThrow( + "Compressor for js files not found. Run: npm install @node-minify/terser" + ); + }); + + test("should create output directory", async () => { + vi.mocked(discoverFiles).mockReturnValue(["file1.js"]); + vi.mocked(groupFilesByType).mockReturnValue({ + js: ["file1.js"], + css: [], + html: [], + json: [], + svg: [], + unknown: [], + }); + + await runAutoMode(mockInputs); + + expect(mkdir).toHaveBeenCalledWith("dist", { recursive: true }); + }); + + test("should process files and set outputs", async () => { + vi.mocked(discoverFiles).mockReturnValue(["file1.js"]); + vi.mocked(groupFilesByType).mockReturnValue({ + js: ["file1.js"], + css: [], + html: [], + json: [], + svg: [], + unknown: [], + }); + vi.mocked(stat).mockResolvedValueOnce({ size: 100 } as any); + vi.mocked(stat).mockResolvedValueOnce({ size: 50 } as any); + + await runAutoMode(mockInputs); + + expect(minify).toHaveBeenCalled(); + expect(setMinifyOutputs).toHaveBeenCalledWith( + expect.objectContaining({ + totalOriginalSize: 100, + totalMinifiedSize: 50, + totalReduction: 50, + }) + ); + }); + + test("should handle mixed file types", async () => { + vi.mocked(discoverFiles).mockReturnValue(["file1.js", "style.css"]); + vi.mocked(groupFilesByType).mockReturnValue({ + js: ["file1.js"], + css: ["style.css"], + html: [], + json: [], + svg: [], + unknown: [], + }); + vi.mocked(selectCompressor).mockImplementation((type) => { + if (type === "js") + return { compressor: "terser", package: "@node-minify/terser" }; + if (type === "css") + return { + compressor: "lightningcss", + package: "@node-minify/lightningcss", + }; + return { + compressor: "no-compress", + package: "@node-minify/no-compress", + }; + }); + + await runAutoMode(mockInputs); + + expect(minify).toHaveBeenCalledTimes(2); + }); + + test("should process files in chunks", async () => { + const files = ["f1.js", "f2.js", "f3.js", "f4.js", "f5.js"]; + vi.mocked(discoverFiles).mockReturnValue(files); + vi.mocked(groupFilesByType).mockReturnValue({ + js: files, + css: [], + html: [], + json: [], + svg: [], + unknown: [], + }); + + await runAutoMode(mockInputs); + + expect(minify).toHaveBeenCalledTimes(5); + }); + + test("should handle partial failures", async () => { + vi.mocked(discoverFiles).mockReturnValue(["success.js", "fail.js"]); + vi.mocked(groupFilesByType).mockReturnValue({ + js: ["success.js", "fail.js"], + css: [], + html: [], + json: [], + svg: [], + unknown: [], + }); + vi.mocked(minify).mockResolvedValueOnce(""); + vi.mocked(minify).mockRejectedValueOnce(new Error("Minify failed")); + + await runAutoMode(mockInputs); + + expect(core.warning).toHaveBeenCalledWith("1 files failed to minify:"); + expect(core.warning).toHaveBeenCalledWith(" - fail.js: Minify failed"); + expect(setMinifyOutputs).toHaveBeenCalledWith( + expect.objectContaining({ + files: expect.arrayContaining([ + expect.objectContaining({ file: "success.js" }), + ]), + }) + ); + }); + + test("should call setFailed when all files fail", async () => { + vi.mocked(discoverFiles).mockReturnValue(["fail.js"]); + vi.mocked(groupFilesByType).mockReturnValue({ + js: ["fail.js"], + css: [], + html: [], + json: [], + svg: [], + unknown: [], + }); + vi.mocked(minify).mockRejectedValue(new Error("Minify failed")); + + await runAutoMode(mockInputs); + + expect(core.setFailed).toHaveBeenCalledWith( + "All files failed to minify" + ); + }); + + test("should include gzip size when requested", async () => { + vi.mocked(discoverFiles).mockReturnValue(["file1.js"]); + vi.mocked(groupFilesByType).mockReturnValue({ + js: ["file1.js"], + css: [], + html: [], + json: [], + svg: [], + unknown: [], + }); + vi.mocked(getFilesizeGzippedRaw).mockResolvedValue(20); + + await runAutoMode({ ...mockInputs, includeGzip: true }); + + expect(getFilesizeGzippedRaw).toHaveBeenCalled(); + expect(setMinifyOutputs).toHaveBeenCalledWith( + expect.objectContaining({ + files: [expect.objectContaining({ gzipSize: 20 })], + }) + ); + }); + + test("should not include gzip size when disabled", async () => { + vi.mocked(discoverFiles).mockReturnValue(["file1.js"]); + vi.mocked(groupFilesByType).mockReturnValue({ + js: ["file1.js"], + css: [], + html: [], + json: [], + svg: [], + unknown: [], + }); + + await runAutoMode({ ...mockInputs, includeGzip: false }); + + expect(getFilesizeGzippedRaw).not.toHaveBeenCalled(); + }); + + test("should handle 0 byte files", async () => { + vi.mocked(discoverFiles).mockReturnValue(["empty.js"]); + vi.mocked(groupFilesByType).mockReturnValue({ + js: ["empty.js"], + css: [], + html: [], + json: [], + svg: [], + unknown: [], + }); + vi.mocked(stat).mockResolvedValue({ size: 0 } as any); + + await runAutoMode(mockInputs); + + expect(setMinifyOutputs).toHaveBeenCalledWith( + expect.objectContaining({ + totalReduction: 0, + }) + ); + }); + + test("should call generateSummary when reportSummary is true", async () => { + vi.mocked(discoverFiles).mockReturnValue(["file1.js"]); + vi.mocked(groupFilesByType).mockReturnValue({ + js: ["file1.js"], + css: [], + html: [], + json: [], + svg: [], + unknown: [], + }); + + await runAutoMode({ ...mockInputs, reportSummary: true }); + + expect(generateSummary).toHaveBeenCalled(); + }); + + test("should not call generateSummary when reportSummary is false", async () => { + vi.mocked(discoverFiles).mockReturnValue(["file1.js"]); + vi.mocked(groupFilesByType).mockReturnValue({ + js: ["file1.js"], + css: [], + html: [], + json: [], + svg: [], + unknown: [], + }); + + await runAutoMode({ ...mockInputs, reportSummary: false }); + + expect(generateSummary).not.toHaveBeenCalled(); + }); + + test("should call setFailed when threshold check fails", async () => { + vi.mocked(discoverFiles).mockReturnValue(["file1.js"]); + vi.mocked(groupFilesByType).mockReturnValue({ + js: ["file1.js"], + css: [], + html: [], + json: [], + svg: [], + unknown: [], + }); + vi.mocked(checkThresholds).mockReturnValue("Threshold exceeded"); + + await runAutoMode(mockInputs); + + expect(core.setFailed).toHaveBeenCalledWith("Threshold exceeded"); + }); + + test("should use correct paths with workingDirectory", async () => { + vi.mocked(discoverFiles).mockReturnValue(["file1.js"]); + vi.mocked(groupFilesByType).mockReturnValue({ + js: ["file1.js"], + css: [], + html: [], + json: [], + svg: [], + unknown: [], + }); + const inputs = { ...mockInputs, workingDirectory: "src" }; + + await runAutoMode(inputs); + + expect(mkdir).toHaveBeenCalledWith(path.join("src", "dist"), { + recursive: true, + }); + }); +}); diff --git a/packages/action/__tests__/runExplicitMode.test.ts b/packages/action/__tests__/runExplicitMode.test.ts new file mode 100644 index 000000000..dba0fb13f --- /dev/null +++ b/packages/action/__tests__/runExplicitMode.test.ts @@ -0,0 +1,229 @@ +/*! node-minify action tests - MIT Licensed */ + +import * as core from "@actions/core"; +import { context } from "@actions/github"; +import { beforeEach, describe, expect, test, vi } from "vitest"; +import { addAnnotations } from "../src/annotations.ts"; +import { runBenchmark } from "../src/benchmark.ts"; +import { checkThresholds } from "../src/checks.ts"; +import { postPRComment } from "../src/comment.ts"; +import { compareWithBase } from "../src/compare.ts"; +import { runExplicitMode } from "../src/index.ts"; +import { validateCompressor } from "../src/inputs.ts"; +import { runMinification } from "../src/minify.ts"; +import { setBenchmarkOutputs, setMinifyOutputs } from "../src/outputs.ts"; +import { + generateBenchmarkSummary, + generateSummary, +} from "../src/reporters/summary.ts"; +import type { ActionInputs, MinifyResult } from "../src/types.ts"; + +vi.mock("@actions/core"); +vi.mock("@actions/github"); +vi.mock("../src/minify.ts"); +vi.mock("../src/outputs.ts"); +vi.mock("../src/checks.ts"); +vi.mock("../src/reporters/summary.ts"); +vi.mock("../src/comment.ts"); +vi.mock("../src/annotations.ts"); +vi.mock("../src/benchmark.ts"); +vi.mock("../src/compare.ts"); +vi.mock("../src/inputs.ts"); + +describe("runExplicitMode", () => { + const mockInputs: ActionInputs = { + compressor: "terser", + input: "src/app.js", + output: "dist/app.min.js", + type: undefined, + options: {}, + reportSummary: true, + reportPRComment: false, + reportAnnotations: false, + benchmark: false, + benchmarkCompressors: [], + workingDirectory: ".", + includeGzip: true, + failOnIncrease: false, + minReduction: 0, + githubToken: "token", + // Auto mode fields (unused but required) + auto: false, + patterns: [], + outputDir: "dist", + additionalIgnore: [], + dryRun: false, + }; + + const mockResult: MinifyResult = { + files: [ + { + file: "src/app.js", + originalSize: 1000, + minifiedSize: 500, + reduction: 50, + gzipSize: 300, + timeMs: 100, + }, + ], + compressor: "terser", + totalOriginalSize: 1000, + totalMinifiedSize: 500, + totalReduction: 50, + totalTimeMs: 100, + }; + + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(validateCompressor).mockImplementation(() => {}); + vi.mocked(runMinification).mockResolvedValue(mockResult); + vi.mocked(checkThresholds).mockReturnValue(null); + // Reset context + (context as any).payload = {}; + }); + + test("1. Compressor validation error", async () => { + vi.mocked(validateCompressor).mockImplementation(() => { + throw new Error("Invalid compressor"); + }); + + await expect(runExplicitMode(mockInputs)).rejects.toThrow( + "Invalid compressor" + ); + expect(validateCompressor).toHaveBeenCalledWith(mockInputs.compressor); + }); + + test("2. Basic minification with outputs set", async () => { + await runExplicitMode({ ...mockInputs, reportSummary: false }); + + expect(runMinification).toHaveBeenCalledWith( + expect.objectContaining({ compressor: "terser" }) + ); + expect(setMinifyOutputs).toHaveBeenCalledWith(mockResult); + expect(core.info).toHaveBeenCalledWith( + expect.stringContaining("Minifying src/app.js with terser...") + ); + }); + + test("3. Summary report generation (when enabled)", async () => { + await runExplicitMode({ ...mockInputs, reportSummary: true }); + + expect(generateSummary).toHaveBeenCalledWith(mockResult); + }); + + test("4. PR comment posting (when in PR context + enabled)", async () => { + (context as any).payload = { pull_request: { number: 123 } }; + const comparisons = [ + { file: "src/app.js", baseSize: 1200, diff: -200 }, + ]; + vi.mocked(compareWithBase).mockResolvedValue(comparisons as any); + + await runExplicitMode({ ...mockInputs, reportPRComment: true }); + + expect(compareWithBase).toHaveBeenCalledWith(mockResult, "token"); + expect(postPRComment).toHaveBeenCalledWith( + mockResult, + "token", + comparisons + ); + }); + + test("5. Annotations reporting (when enabled)", async () => { + await runExplicitMode({ ...mockInputs, reportAnnotations: true }); + + expect(addAnnotations).toHaveBeenCalledWith(mockResult); + }); + + test("6. Benchmark mode disabled (skip benchmark)", async () => { + await runExplicitMode({ ...mockInputs, benchmark: false }); + + expect(runBenchmark).not.toHaveBeenCalled(); + expect(setBenchmarkOutputs).not.toHaveBeenCalled(); + }); + + test("7. Benchmark mode enabled with multiple compressors", async () => { + const benchmarkResult = { + results: [], + recommended: "esbuild", + bestCompression: "esbuild", + bestSpeed: "swc", + }; + vi.mocked(runBenchmark).mockResolvedValue(benchmarkResult as any); + const inputs = { + ...mockInputs, + benchmark: true, + benchmarkCompressors: ["terser", "esbuild"], + }; + + await runExplicitMode(inputs); + + expect(runBenchmark).toHaveBeenCalledWith(inputs); + expect(setBenchmarkOutputs).toHaveBeenCalledWith(benchmarkResult); + expect(generateBenchmarkSummary).toHaveBeenCalledWith(benchmarkResult); + expect(core.info).toHaveBeenCalledWith( + expect.stringContaining( + "Running benchmark with compressors: terser, esbuild..." + ) + ); + }); + + test("8. Benchmark winner logging", async () => { + const benchmarkResult = { + results: [], + recommended: "esbuild", + }; + vi.mocked(runBenchmark).mockResolvedValue(benchmarkResult as any); + + await runExplicitMode({ ...mockInputs, benchmark: true }); + + expect(core.info).toHaveBeenCalledWith("🏆 Benchmark winner: esbuild"); + }); + + test("9. Threshold check passes", async () => { + vi.mocked(checkThresholds).mockReturnValue(null); + + await runExplicitMode(mockInputs); + + expect(core.setFailed).not.toHaveBeenCalled(); + expect(core.info).toHaveBeenCalledWith( + expect.stringContaining("✅ Minification complete!") + ); + }); + + test("10. Threshold check fails (calls setFailed)", async () => { + vi.mocked(checkThresholds).mockReturnValue( + "Reduction 50% is below minimum 60%" + ); + + await runExplicitMode(mockInputs); + + expect(core.setFailed).toHaveBeenCalledWith( + "Reduction 50% is below minimum 60%" + ); + }); + + test("11. No PR comment when not in PR context", async () => { + (context as any).payload = {}; // No pull_request + + await runExplicitMode({ ...mockInputs, reportPRComment: true }); + + expect(compareWithBase).not.toHaveBeenCalled(); + expect(postPRComment).not.toHaveBeenCalled(); + }); + + test("12. Combined: summary + PR comment + annotations", async () => { + (context as any).payload = { pull_request: { number: 123 } }; + const inputs = { + ...mockInputs, + reportSummary: true, + reportPRComment: true, + reportAnnotations: true, + }; + + await runExplicitMode(inputs); + + expect(generateSummary).toHaveBeenCalled(); + expect(postPRComment).toHaveBeenCalled(); + expect(addAnnotations).toHaveBeenCalled(); + }); +}); diff --git a/packages/action/__tests__/validate.test.ts b/packages/action/__tests__/validate.test.ts new file mode 100644 index 000000000..f0a63e0f0 --- /dev/null +++ b/packages/action/__tests__/validate.test.ts @@ -0,0 +1,66 @@ +/*! node-minify action tests - MIT Licensed */ + +import { describe, expect, test } from "vitest"; +import { validateOutputDir } from "../src/validate.ts"; + +describe("validateOutputDir", () => { + test("does not throw when output is outside all source patterns", () => { + expect(() => { + validateOutputDir("dist", ["src/**/*.js"]); + }).not.toThrow(); + }); + + test("throws when output directory is inside a source pattern", () => { + expect(() => { + validateOutputDir("src", ["src/**/*.js"]); + }).toThrow(/output-dir cannot be inside/); + }); + + test("throws when output is nested inside source pattern", () => { + expect(() => { + validateOutputDir("src/dist", ["src/**/*.js"]); + }).toThrow(/output-dir cannot be inside/); + }); + + test("does not throw when output is outside multiple source patterns", () => { + expect(() => { + validateOutputDir("dist", ["src/**/*.js", "lib/**/*.css"]); + }).not.toThrow(); + }); + + test("throws when output matches any source pattern in array", () => { + expect(() => { + validateOutputDir("lib", ["src/**/*.js", "lib/**/*.css"]); + }).toThrow(/output-dir cannot be inside/); + }); + + test("throws when output nested in source pattern using relative path", () => { + expect(() => { + validateOutputDir("src/dist", ["src/**"]); + }).toThrow(/output-dir cannot be inside/); + }); + + test("throws when output same as source pattern using relative path", () => { + expect(() => { + validateOutputDir("./src", ["src/**"]); + }).toThrow(/output-dir cannot be inside/); + }); + + test("does not throw when output is outside using relative path traversal", () => { + expect(() => { + validateOutputDir("../outside", ["src/**"]); + }).not.toThrow(); + }); + + test("error message includes output directory name", () => { + expect(() => { + validateOutputDir("src/build", ["src/**/*.js"]); + }).toThrow(/src\/build/); + }); + + test("error message includes source pattern", () => { + expect(() => { + validateOutputDir("src/build", ["src/**/*.js"]); + }).toThrow(/src\/\*\*\/\*\.js/); + }); +}); diff --git a/packages/action/action.yml b/packages/action/action.yml index b08b600c0..b2952115f 100644 --- a/packages/action/action.yml +++ b/packages/action/action.yml @@ -8,10 +8,28 @@ branding: inputs: input: description: "Files to minify (glob pattern or path)" - required: true + required: false output: description: "Output file path" - required: true + required: false + auto: + description: "Enable zero-config auto mode" + required: false + default: "false" + patterns: + description: "Custom glob patterns for auto mode (comma-separated)" + required: false + output-dir: + description: "Output directory for auto mode" + required: false + default: "dist" + ignore: + description: "Additional ignore patterns for auto mode (comma-separated)" + required: false + dry-run: + description: "Preview mode - show what would be processed without minifying" + required: false + default: "false" compressor: description: | Compressor to use. diff --git a/packages/action/src/reporters/annotations.ts b/packages/action/src/annotations.ts similarity index 97% rename from packages/action/src/reporters/annotations.ts rename to packages/action/src/annotations.ts index 738783b10..cd7ed90f3 100644 --- a/packages/action/src/reporters/annotations.ts +++ b/packages/action/src/annotations.ts @@ -5,7 +5,7 @@ */ import { error, notice, warning } from "@actions/core"; -import type { MinifyResult } from "../types.ts"; +import type { MinifyResult } from "./types.ts"; const LOW_REDUCTION_THRESHOLD = 20; const VERY_LOW_REDUCTION_THRESHOLD = 5; diff --git a/packages/action/src/autoDetect.ts b/packages/action/src/autoDetect.ts new file mode 100644 index 000000000..859e319af --- /dev/null +++ b/packages/action/src/autoDetect.ts @@ -0,0 +1,111 @@ +/*! + * node-minify + * Copyright (c) 2011-2026 Rodolphe Stoclin + * MIT Licensed + */ + +import path from "node:path"; + +/** + * Supported file types for auto-detection. + */ +export type FileType = "js" | "css" | "html" | "json" | "svg" | "unknown"; + +/** + * Compressor selection result with package information. + */ +export interface CompressorSelection { + compressor: string; + type?: "js" | "css"; + package: string; +} + +/** + * Maps file extensions to file types. + * TypeScript extensions (.ts, .tsx, .mts, .cts) are excluded as they require pre-compilation. + */ +const EXTENSION_MAP: Record = { + ".js": "js", + ".mjs": "js", + ".cjs": "js", + ".jsx": "js", + ".css": "css", + ".html": "html", + ".htm": "html", + ".json": "json", + ".svg": "svg", +}; + +/** + * Maps file types to recommended compressors. + */ +const COMPRESSOR_MAP: Record> = { + js: { compressor: "terser" }, + css: { compressor: "lightningcss" }, + html: { compressor: "html-minifier" }, + json: { compressor: "jsonminify" }, + svg: { compressor: "svgo" }, + unknown: { compressor: "no-compress" }, +}; + +/** + * Detects the file type based on file extension. + * + * @param filePath - Path to the file + * @returns Detected file type or "unknown" if not recognized + * + * @example + * detectFileType("app.js") // "js" + * detectFileType("styles.css") // "css" + * detectFileType("app.ts") // "unknown" (TypeScript excluded) + */ +export function detectFileType(filePath: string): FileType { + const ext = path.extname(filePath).toLowerCase(); + return EXTENSION_MAP[ext] ?? "unknown"; +} + +/** + * Selects the recommended compressor for a given file type. + * + * @param fileType - The file type to select a compressor for + * @returns Compressor selection with package name + * + * @example + * selectCompressor("js") // { compressor: "terser", package: "@node-minify/terser" } + * selectCompressor("css") // { compressor: "lightningcss", package: "@node-minify/lightningcss" } + */ +export function selectCompressor(fileType: FileType): CompressorSelection { + const { compressor, type } = COMPRESSOR_MAP[fileType]; + return { + compressor, + type, + package: `@node-minify/${compressor}`, + }; +} + +/** + * Groups files by their detected file type. + * + * @param files - Array of file paths to group + * @returns Object with file types as keys and arrays of matching files as values + * + * @example + * groupFilesByType(["a.js", "b.css", "c.js"]) + * // { js: ["a.js", "c.js"], css: ["b.css"], html: [], json: [], svg: [], unknown: [] } + */ +export function groupFilesByType(files: string[]): Record { + const groups: Record = { + js: [], + css: [], + html: [], + json: [], + svg: [], + unknown: [], + }; + + for (const file of files) { + groups[detectFileType(file)].push(file); + } + + return groups; +} diff --git a/packages/action/src/benchmark.ts b/packages/action/src/benchmark.ts index ed6336868..e1f51f19a 100644 --- a/packages/action/src/benchmark.ts +++ b/packages/action/src/benchmark.ts @@ -21,7 +21,11 @@ import type { ActionInputs, BenchmarkResult } from "./types.ts"; export async function runBenchmark( inputs: ActionInputs ): Promise { - const inputPath = resolve(inputs.workingDirectory, inputs.input); + const { input } = inputs; + if (!input) { + throw new Error("Input file is required for benchmark mode"); + } + const inputPath = resolve(inputs.workingDirectory, input); // Filter out compressors that require 'type' when type is not provided const compressors = inputs.type @@ -49,7 +53,7 @@ export async function runBenchmark( } return { - file: inputs.input, + file: input, originalSize: fileResult.originalSizeBytes, compressors: fileResult.results.map((r) => ({ compressor: r.compressor, diff --git a/packages/action/src/reporters/comment.ts b/packages/action/src/comment.ts similarity index 97% rename from packages/action/src/reporters/comment.ts rename to packages/action/src/comment.ts index 4f1cd2193..3caae944b 100644 --- a/packages/action/src/reporters/comment.ts +++ b/packages/action/src/comment.ts @@ -7,8 +7,8 @@ import { info, warning } from "@actions/core"; import { context, getOctokit } from "@actions/github"; import { prettyBytes } from "@node-minify/utils"; -import { formatChange } from "../compare.ts"; -import type { ComparisonResult, MinifyResult } from "../types.ts"; +import { formatChange } from "./compare.ts"; +import type { ComparisonResult, MinifyResult } from "./types.ts"; const COMMENT_TAG = ""; diff --git a/packages/action/src/discover.ts b/packages/action/src/discover.ts new file mode 100644 index 000000000..2dd0e30e4 --- /dev/null +++ b/packages/action/src/discover.ts @@ -0,0 +1,93 @@ +/*! + * node-minify + * Copyright (c) 2011-2026 Rodolphe Stoclin + * MIT Licensed + */ + +import path from "node:path"; +import * as core from "@actions/core"; +import fg from "fast-glob"; + +export interface DiscoverOptions { + patterns?: string[]; + ignore?: string[]; + workingDirectory?: string; + dryRun?: boolean; +} + +export const DEFAULT_PATTERNS = [ + "src/**/*.{js,mjs,cjs,jsx,css,html,htm,json,svg}", + "app/**/*.{js,mjs,cjs,jsx,css,html,htm,json,svg}", + "lib/**/*.{js,mjs,cjs,jsx,css,html,htm,json,svg}", + "styles/**/*.css", + "*.{js,mjs,cjs,css,html,htm}", +]; + +export const DEFAULT_IGNORES = [ + "**/node_modules/**", + "**/dist/**", + "**/build/**", + "**/.next/**", + "**/*.min.{js,css}", + "**/*.d.ts", + "**/__tests__/**", + "**/.*", +]; + +/** + * Discover files matching patterns with smart defaults for common project structures. + * + * @param options - Discovery options including patterns, ignore rules, working directory, and dry-run mode + * @returns Array of discovered file paths relative to the working directory + */ +export function discoverFiles(options: DiscoverOptions = {}): string[] { + const patterns = options.patterns ?? DEFAULT_PATTERNS; + const ignore = [...DEFAULT_IGNORES, ...(options.ignore ?? [])]; + const cwd = options.workingDirectory ?? process.cwd(); + + const files = fg.globSync(patterns, { + cwd, + ignore, + followSymbolicLinks: false, + onlyFiles: true, + }); + + if (options.dryRun) { + core.info(`[dry-run] Would process ${files.length} files`); + for (const file of files) { + core.info(` - ${file}`); + } + } + + if (files.length === 0) { + core.warning("No files found matching patterns"); + } + + return files; +} + +/** + * Generate output path by joining output directory with input file path. + * + * @param inputFile - Input file path (relative or absolute) + * @param outputDir - Output directory path + * @returns Combined output path preserving input file's directory structure + */ +export function generateOutputPath( + inputFile: string, + outputDir: string +): string { + // Normalize first to resolve internal '..' segments + // e.g. "foo/../../bar.js" -> "../bar.js" + let cleanInput = path.normalize(inputFile); + + if (path.isAbsolute(cleanInput)) { + const parsed = path.parse(cleanInput); + cleanInput = cleanInput.slice(parsed.root.length); + } + + // Remove leading directory traversal sequences + cleanInput = cleanInput.replace(/^(\.\.[/\\])+/, ""); + + return path.join(outputDir, cleanInput); +} diff --git a/packages/action/src/index.ts b/packages/action/src/index.ts index 02f706d76..bb285a626 100644 --- a/packages/action/src/index.ts +++ b/packages/action/src/index.ts @@ -4,81 +4,337 @@ * MIT Licensed */ -import { info, setFailed } from "@actions/core"; +import { mkdir, stat } from "node:fs/promises"; +import path from "node:path"; +import { performance } from "node:perf_hooks"; +import { info, setFailed, warning } from "@actions/core"; import { context } from "@actions/github"; +import { minify } from "@node-minify/core"; +import { getFilesizeGzippedRaw, resolveCompressor } from "@node-minify/utils"; +import { addAnnotations } from "./annotations.ts"; +import type { FileType } from "./autoDetect.ts"; +import { + detectFileType, + groupFilesByType, + selectCompressor, +} from "./autoDetect.ts"; import { runBenchmark } from "./benchmark.ts"; import { checkThresholds } from "./checks.ts"; +import { postPRComment } from "./comment.ts"; import { compareWithBase } from "./compare.ts"; +import { discoverFiles, generateOutputPath } from "./discover.ts"; import { parseInputs, validateCompressor } from "./inputs.ts"; import { runMinification } from "./minify.ts"; import { setBenchmarkOutputs, setMinifyOutputs } from "./outputs.ts"; -import { addAnnotations } from "./reporters/annotations.ts"; -import { postPRComment } from "./reporters/comment.ts"; import { generateBenchmarkSummary, generateSummary, } from "./reporters/summary.ts"; +import type { ActionInputs, FileResult, MinifyResult } from "./types.ts"; /** - * Orchestrates the minification workflow for the GitHub Action. + * Splits an array into chunks of the specified size. * - * Parses and validates inputs, runs the minification, and persists outputs. - * Optionally generates a summary, posts a pull-request comment when running in a PR, and adds annotations based on inputs. - * If configured thresholds are violated or an error is thrown, signals action failure with an explanatory message. + * @param array - The array to split + * @param size - Maximum size of each chunk + * @returns Array of chunks */ -async function run(): Promise { - try { - const inputs = parseInputs(); +export function chunkArray(array: T[], size: number): T[][] { + if (!Number.isInteger(size) || size <= 0) { + throw new TypeError( + `chunkArray size must be a positive integer, got: ${size}` + ); + } + const chunks: T[][] = []; + for (let i = 0; i < array.length; i += size) { + chunks.push(array.slice(i, i + size)); + } + return chunks; +} + +/** + * Get the size of a file in bytes. + */ +async function getFileSize(filePath: string): Promise { + const stats = await stat(filePath); + return stats.size; +} + +/** + * Runs the explicit mode minification workflow (original behavior). + * + * @param inputs - Validated action inputs with input/output paths + * @returns A promise that resolves when the operation completes. + */ +export async function runExplicitMode(inputs: ActionInputs): Promise { + validateCompressor(inputs.compressor); + + info(`Minifying ${inputs.input} with ${inputs.compressor}...`); + + const result = await runMinification(inputs); + + setMinifyOutputs(result); - validateCompressor(inputs.compressor); + if (inputs.reportSummary) { + await generateSummary(result); + } - info(`Minifying ${inputs.input} with ${inputs.compressor}...`); + if (inputs.reportPRComment && context.payload.pull_request) { + const comparisons = await compareWithBase(result, inputs.githubToken); + await postPRComment(result, inputs.githubToken, comparisons); + } - const result = await runMinification(inputs); + if (inputs.reportAnnotations) { + addAnnotations(result); + } - setMinifyOutputs(result); + if (inputs.benchmark) { + info( + `Running benchmark with compressors: ${inputs.benchmarkCompressors.join(", ")}...` + ); + const benchmarkResult = await runBenchmark(inputs); + setBenchmarkOutputs(benchmarkResult); if (inputs.reportSummary) { - await generateSummary(result); + await generateBenchmarkSummary(benchmarkResult); } - if (inputs.reportPRComment && context.payload.pull_request) { - const comparisons = await compareWithBase( - result, - inputs.githubToken - ); - await postPRComment(result, inputs.githubToken, comparisons); + if (benchmarkResult.recommended) { + info(`🏆 Benchmark winner: ${benchmarkResult.recommended}`); } + } - if (inputs.reportAnnotations) { - addAnnotations(result); - } + const thresholdError = checkThresholds(result.totalReduction, inputs); + if (thresholdError) { + setFailed(thresholdError); + return; + } + + info( + `✅ Minification complete! ${result.totalReduction.toFixed(1)}% reduction in ${result.totalTimeMs}ms` + ); +} + +/** + * Runs the auto mode minification workflow with file discovery and type-based processing. + * + * @param inputs - Validated action inputs with auto mode enabled + * @returns A promise that resolves when the operation completes. + */ +export async function runAutoMode(inputs: ActionInputs): Promise { + // Normalize outputDir and build ignore glob to prevent re-processing + let normalizedOutputDir = inputs.outputDir + .replace(/^\.\//, "") + .replace(/\\/g, "/"); + // Preserve "." for current directory to avoid invalid glob "**//**" + if (normalizedOutputDir === "") { + normalizedOutputDir = "."; + } + const outputDirIgnore = `**/${normalizedOutputDir}/**`; + + const files = discoverFiles({ + patterns: inputs.patterns, + ignore: [...(inputs.additionalIgnore ?? []), outputDirIgnore], + workingDirectory: inputs.workingDirectory, + dryRun: inputs.dryRun, + }); + + const emptyResult: MinifyResult = { + files: [], + compressor: "auto", + totalOriginalSize: 0, + totalMinifiedSize: 0, + totalReduction: 0, + totalTimeMs: 0, + }; + + if (files.length === 0) { + warning("No files found matching patterns"); + setMinifyOutputs(emptyResult); + return; + } + + if (inputs.dryRun) { + info(`[dry-run] Would process ${files.length} files`); + setMinifyOutputs(emptyResult); + return; + } - if (inputs.benchmark) { - info( - `Running benchmark with compressors: ${inputs.benchmarkCompressors.join(", ")}...` + const grouped = groupFilesByType(files); + + // Check compressor availability first + for (const [type, typeFiles] of Object.entries(grouped)) { + if (typeFiles.length === 0) continue; + const { compressor, package: pkg } = selectCompressor(type as FileType); + try { + await resolveCompressor(compressor); + } catch { + throw new Error( + `Compressor for ${type} files not found. Run: npm install ${pkg}` ); - const benchmarkResult = await runBenchmark(inputs); - setBenchmarkOutputs(benchmarkResult); + } + } - if (inputs.reportSummary) { - await generateBenchmarkSummary(benchmarkResult); - } + // Create output directory + await mkdir(path.join(inputs.workingDirectory, inputs.outputDir), { + recursive: true, + }); + + // Process files with concurrency limit + const allResults: FileResult[] = []; + const failures: { file: string; error: string }[] = []; + const chunks = chunkArray(files, 4); + + for (const chunk of chunks) { + const results = await Promise.allSettled( + chunk.map(async (file) => { + const outputPath = generateOutputPath(file, inputs.outputDir); + const inputPath = path.join(inputs.workingDirectory, file); + const fullOutputPath = path.join( + inputs.workingDirectory, + outputPath + ); + + // Ensure output directory exists + await mkdir(path.dirname(fullOutputPath), { recursive: true }); + + const fileType = detectFileType(file); + + const { compressor, type } = selectCompressor( + fileType as FileType + ); + const { compressor: compressorFn, label } = + await resolveCompressor(compressor); + + const originalSize = await getFileSize(inputPath); + const startTime = performance.now(); + + await minify({ + compressor: compressorFn, + input: inputPath, + output: fullOutputPath, + ...(type && { type }), + }); - if (benchmarkResult.recommended) { - info(`🏆 Benchmark winner: ${benchmarkResult.recommended}`); + const endTime = performance.now(); + const timeMs = Math.round(endTime - startTime); + const minifiedSize = await getFileSize(fullOutputPath); + const reduction = + originalSize > 0 + ? ((originalSize - minifiedSize) / originalSize) * 100 + : 0; + + let gzipSize: number | undefined; + if (inputs.includeGzip) { + gzipSize = await getFilesizeGzippedRaw(fullOutputPath); + } + + return { + file, + originalSize, + minifiedSize, + reduction, + gzipSize, + timeMs, + compressor: label, + } as FileResult & { compressor: string }; + }) + ); + + for (const [i, result] of results.entries()) { + const file = chunk[i]; + if (result.status === "fulfilled") { + allResults.push(result.value); + } else if (file !== undefined) { + failures.push({ + file, + error: + result.reason instanceof Error + ? result.reason.message + : String(result.reason), + }); } } + } - const thresholdError = checkThresholds(result.totalReduction, inputs); - if (thresholdError) { - setFailed(thresholdError); - return; + // Report results + if (failures.length > 0) { + warning(`${failures.length} files failed to minify:`); + for (const { file, error } of failures) { + warning(` - ${file}: ${error}`); } + } - info( - `✅ Minification complete! ${result.totalReduction.toFixed(1)}% reduction in ${result.totalTimeMs}ms` - ); + if (allResults.length === 0) { + setFailed("All files failed to minify"); + return; + } + + // Generate summary using existing function (stub for now - Task 6 will implement generateAutoModeSummary) + const totalOriginalSize = allResults.reduce( + (sum, r) => sum + r.originalSize, + 0 + ); + const totalMinifiedSize = allResults.reduce( + (sum, r) => sum + r.minifiedSize, + 0 + ); + const totalReduction = + totalOriginalSize > 0 + ? ((totalOriginalSize - totalMinifiedSize) / totalOriginalSize) * + 100 + : 0; + const totalTimeMs = allResults.reduce((sum, r) => sum + r.timeMs, 0); + + const minifyResult: MinifyResult = { + files: allResults, + compressor: "auto", + totalOriginalSize, + totalMinifiedSize, + totalReduction, + totalTimeMs, + }; + + setMinifyOutputs(minifyResult); + + if (inputs.reportSummary) { + await generateSummary(minifyResult); + } + + const thresholdError = checkThresholds(minifyResult.totalReduction, inputs); + if (thresholdError) { + setFailed(thresholdError); + return; + } + + info( + `✅ Auto mode complete! Processed ${allResults.length} files with ${totalReduction.toFixed(1)}% total reduction in ${totalTimeMs}ms` + ); +} + +export const _internal = { + runAutoMode, + runExplicitMode, +}; + +/** + * Orchestrates the minification workflow for the GitHub Action. + * + * Parses and validates inputs, runs the minification, and persists outputs. + * Optionally generates a summary, posts a pull-request comment when running in a PR, and adds annotations based on inputs. + * If configured thresholds are violated or an error is thrown, signals action failure with an explanatory message. + * + * @returns A promise that resolves when the operation completes. + */ +export async function run(): Promise { + try { + const inputs = parseInputs(); + + if (inputs.auto) { + await _internal.runAutoMode(inputs); + } else { + await _internal.runExplicitMode(inputs); + } } catch (error) { if (error instanceof Error) { setFailed(error.message); diff --git a/packages/action/src/inputs.ts b/packages/action/src/inputs.ts index 87dd1573f..7d23a3c9c 100644 --- a/packages/action/src/inputs.ts +++ b/packages/action/src/inputs.ts @@ -4,9 +4,12 @@ * MIT Licensed */ +import path from "node:path"; import { getBooleanInput, getInput, warning } from "@actions/core"; import { isBuiltInCompressor } from "@node-minify/utils"; +import { DEFAULT_PATTERNS } from "./discover.ts"; import type { ActionInputs } from "./types.ts"; +import { validateOutputDir } from "./validate.ts"; const TYPE_REQUIRED_COMPRESSORS = ["esbuild", "yui"]; @@ -19,6 +22,20 @@ const DEPRECATED_COMPRESSORS: Record = { sqwish: "sqwish is no longer maintained. Use 'lightningcss' or 'clean-css' instead.", }; +/** + * Parse comma-separated string into array of trimmed non-empty strings. + * + * @param value - Comma-separated string + * @returns Array of trimmed non-empty strings + */ +function parseCommaSeparated(value: string): string[] { + if (!value) return []; + return value + .split(",") + .map((s) => s.trim()) + .filter(Boolean); +} + /** * Parse and validate GitHub Action inputs into an ActionInputs object. * @@ -35,9 +52,39 @@ const DEPRECATED_COMPRESSORS: Record = { * is not provided. * @throws Error if the `options` input is present but is not valid JSON. * @throws Error if the `type` input is provided but is not 'js' or 'css'. + * @throws Error if auto mode is disabled and input/output are not provided. */ export function parseInputs(): ActionInputs { const compressor = getInput("compressor") || "terser"; + const auto = getBooleanInput("auto"); + const dryRun = getBooleanInput("dry-run"); + const outputDir = getInput("output-dir") || "dist"; + + // Sanitize output-dir to prevent path traversal + const segments = outputDir.split(/[/\\]/); + if (segments.includes("..") || path.isAbsolute(outputDir)) { + throw new Error( + 'output-dir must be a relative path without ".." segments' + ); + } + const patterns = parseCommaSeparated(getInput("patterns")); + const additionalIgnore = parseCommaSeparated(getInput("ignore")); + + const input = getInput("input"); + const output = getInput("output"); + + if (!auto && (!input || !output)) { + throw new Error( + "Explicit mode requires both 'input' and 'output'. Enable 'auto' mode or provide both inputs." + ); + } + + if (auto) { + const patternsToValidate = + patterns.length > 0 ? patterns : DEFAULT_PATTERNS; + const workingDir = getInput("working-directory") || "."; + validateOutputDir(outputDir, patternsToValidate, workingDir); + } // Validate type input explicitly const typeRaw = getInput("type"); @@ -86,8 +133,8 @@ export function parseInputs(): ActionInputs { })(); return { - input: getInput("input", { required: true }), - output: getInput("output", { required: true }), + input, + output, compressor, type, options, @@ -111,6 +158,12 @@ export function parseInputs(): ActionInputs { includeGzip: getBooleanInput("include-gzip"), workingDirectory: getInput("working-directory") || ".", githubToken: getInput("github-token") || process.env.GITHUB_TOKEN, + auto, + patterns: patterns.length > 0 ? patterns : undefined, + outputDir, + additionalIgnore: + additionalIgnore.length > 0 ? additionalIgnore : undefined, + dryRun, }; } diff --git a/packages/action/src/minify.ts b/packages/action/src/minify.ts index 972ed09fd..153fb1e16 100644 --- a/packages/action/src/minify.ts +++ b/packages/action/src/minify.ts @@ -39,8 +39,14 @@ async function getFileSize(filePath: string): Promise { export async function runMinification( inputs: ActionInputs ): Promise { - const inputPath = resolve(inputs.workingDirectory, inputs.input); - const outputPath = resolve(inputs.workingDirectory, inputs.output); + const { input, output } = inputs; + if (!input || !output) { + throw new Error( + "Input and output files are required for explicit mode" + ); + } + const inputPath = resolve(inputs.workingDirectory, input); + const outputPath = resolve(inputs.workingDirectory, output); const originalSize = await getFileSize(inputPath); const { compressor, label } = await resolveCompressor(inputs.compressor); @@ -72,7 +78,7 @@ export async function runMinification( } const fileResult: FileResult = { - file: inputs.input, + file: input, originalSize, minifiedSize, reduction, diff --git a/packages/action/src/reporters/summary.ts b/packages/action/src/reporters/summary.ts index 9f0aa7407..a9027341b 100644 --- a/packages/action/src/reporters/summary.ts +++ b/packages/action/src/reporters/summary.ts @@ -6,7 +6,47 @@ import { summary } from "@actions/core"; import { prettyBytes } from "@node-minify/utils"; -import type { BenchmarkResult, MinifyResult } from "../types.ts"; +import { detectFileType, type FileType } from "../autoDetect.ts"; +import type { + ActionInputs, + BenchmarkResult, + FileResult, + MinifyResult, +} from "../types.ts"; + +/** + * Maps file types to their corresponding emojis for visual identification in the summary. + */ +const TYPE_EMOJI: Record = { + js: "📜", + css: "🎨", + html: "🌐", + json: "📋", + svg: "🖼️", + unknown: "❓", +}; + +/** + * Maps file types to their human-readable labels for group headings. + */ +const TYPE_LABEL: Record = { + js: "JavaScript", + css: "CSS", + html: "HTML", + json: "JSON", + svg: "SVG", + unknown: "Unknown", +}; + +/** + * Returns the emoji corresponding to a given file type. + * + * @param type - The file type to get an emoji for + * @returns An emoji string or a default question mark + */ +function getTypeEmoji(type: FileType): string { + return TYPE_EMOJI[type] ?? "❓"; +} /** * Generate a GitHub Actions summary reporting per-file minification metrics and totals. @@ -48,6 +88,102 @@ export async function generateSummary(result: MinifyResult): Promise { .write(); } +/** + * Generate a GitHub Actions summary for auto mode results, grouped by file type. + * + * Creates separate tables for each file type (JS, CSS, HTML, etc.), including + * original/minified sizes and reduction percentages, and appends a grand total. + * + * @param results - Array of minification results to aggregate and display + * @param inputs - Action inputs containing configuration such as includeGzip + */ +export async function generateAutoModeSummary( + results: MinifyResult[], + inputs: ActionInputs +): Promise { + if (results.length === 0) { + await summary.addRaw("No files were processed.").write(); + return; + } + + const allFiles = results.flatMap((r) => r.files); + const groups: Record = { + js: [], + css: [], + html: [], + json: [], + svg: [], + unknown: [], + }; + + for (const file of allFiles) { + groups[detectFileType(file.file)].push(file); + } + + let totalOriginal = 0; + let totalMinified = 0; + + summary.addHeading("📦 node-minify Auto Mode Results", 2); + + for (const type of Object.keys(groups) as FileType[]) { + const files = groups[type]; + if (files.length === 0) continue; + + const emoji = getTypeEmoji(type); + const label = TYPE_LABEL[type]; + summary.addHeading(`${emoji} ${label}`, 3); + + const rows = files.map((f) => { + totalOriginal += f.originalSize; + totalMinified += f.minifiedSize; + + const row = [ + { data: `\`${f.file}\`` }, + { data: prettyBytes(f.originalSize) }, + { data: prettyBytes(f.minifiedSize) }, + { data: `${f.reduction.toFixed(1)}%` }, + ]; + + if (inputs.includeGzip) { + row.push({ + data: f.gzipSize != null ? prettyBytes(f.gzipSize) : "-", + }); + } + + row.push({ data: `${f.timeMs}ms` }); + + return row; + }); + + const headers = [ + { data: "File", header: true }, + { data: "Original", header: true }, + { data: "Minified", header: true }, + { data: "Reduction", header: true }, + ]; + + if (inputs.includeGzip) { + headers.push({ data: "Gzip", header: true }); + } + + headers.push({ data: "Time", header: true }); + + summary.addTable([headers, ...rows]); + } + + const totalReduction = + totalOriginal > 0 + ? ((totalOriginal - totalMinified) / totalOriginal) * 100 + : 0; + + await summary + .addBreak() + .addRaw( + `**Total:** ${prettyBytes(totalOriginal)} → ${prettyBytes(totalMinified)} (${totalReduction.toFixed(1)}% reduction)` + ) + .write(); +} + /** * Generate a Markdown benchmark summary comparing compressors and write it to the GitHub Actions summary. * diff --git a/packages/action/src/types.ts b/packages/action/src/types.ts index 5117e0e26..9701a213d 100644 --- a/packages/action/src/types.ts +++ b/packages/action/src/types.ts @@ -5,8 +5,16 @@ */ export interface ActionInputs { - input: string; - output: string; + /** + * Input file(s) to minify (glob pattern or path). + * Optional when `auto` is true. + */ + input?: string; + /** + * Output file path. + * Optional when `auto` is true. + */ + output?: string; compressor: string; /** * File type hint for compressors that handle multiple types. @@ -25,6 +33,30 @@ export interface ActionInputs { includeGzip: boolean; workingDirectory: string; githubToken?: string; + /** + * Enable zero-config auto mode. + * When true, automatically discovers and processes files based on patterns. + */ + auto: boolean; + /** + * Custom glob patterns for auto mode (e.g., ["src/**\/*.js", "lib/**\/*.ts"]). + * Only used when `auto` is true. + */ + patterns?: string[]; + /** + * Output directory for auto mode. + * Defaults to "dist". + */ + outputDir: string; + /** + * Additional ignore patterns for auto mode (e.g., ["**\/*.test.js", "**\/*.spec.ts"]). + * Only used when `auto` is true. + */ + additionalIgnore?: string[]; + /** + * Preview mode - show what would be processed without actually minifying. + */ + dryRun: boolean; } export interface FileResult { diff --git a/packages/action/src/validate.ts b/packages/action/src/validate.ts new file mode 100644 index 000000000..d5c6cfd16 --- /dev/null +++ b/packages/action/src/validate.ts @@ -0,0 +1,52 @@ +/*! + * node-minify + * Copyright (c) 2011-2026 Rodolphe Stoclin + * MIT Licensed + */ + +import path from "node:path"; + +/** + * Validates that the output directory is not inside any source pattern. + * Prevents infinite loop risk when output overlaps with source files. + * + * @param outputDir - The output directory path + * @param patterns - Array of source file patterns (glob patterns) + * @param workingDirectory - Base directory for resolving relative paths (defaults to ".") + * @throws Error if outputDir is inside any source pattern + */ +export function validateOutputDir( + outputDir: string, + patterns: string[], + workingDirectory = "." +): void { + const absOutputDir = path.resolve(workingDirectory, outputDir); + + for (const pattern of patterns) { + // Extract base directory from glob pattern (everything before first *) + const baseDir = pattern.split("*")[0]; + + // Skip root-level patterns (e.g., "*.js") - they don't recurse into subdirs + // so outputDir in a subdir like "dist" won't cause infinite loops + if (!baseDir || baseDir === "") { + continue; + } + + const absBaseDir = path.resolve(workingDirectory, baseDir); + + // Check if output dir is same as or inside base dir + const rel = path.relative(absBaseDir, absOutputDir); + + // If rel is empty, they are same. + // If rel does not start with '..', output is inside base. + // Exception: check if rel is absolute (different drives on Windows) + const isInside = + rel === "" || (!rel.startsWith("..") && !path.isAbsolute(rel)); + + if (isInside) { + throw new Error( + `output-dir cannot be inside source pattern: '${outputDir}' overlaps with '${pattern}'` + ); + } + } +} diff --git a/packages/utils/__tests__/wildcards-ignore.test.ts b/packages/utils/__tests__/wildcards-ignore.test.ts new file mode 100644 index 000000000..5a68d0fb0 --- /dev/null +++ b/packages/utils/__tests__/wildcards-ignore.test.ts @@ -0,0 +1,114 @@ +/*! + * node-minify + * Copyright (c) 2011-2026 Rodolphe Stoclin + * MIT Licensed + */ + +import fg from "fast-glob"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import type { WildcardOptions } from "../src/wildcards.ts"; +import { DEFAULT_IGNORES, wildcards } from "../src/wildcards.ts"; + +// Mock fast-glob globally +vi.mock("fast-glob", () => { + return { + default: { + globSync: vi.fn(), + convertPathToPattern: vi.fn((path) => path), + }, + }; +}); + +describe("wildcards with ignore patterns", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + test("should work with string publicFolder (backward compat)", () => { + vi.mocked(fg.globSync).mockReturnValue(["public/app.js"]); + + const result = wildcards("*.js", "public/"); + expect(result).toEqual({ input: ["public/app.js"] }); + expect(fg.globSync).toHaveBeenCalledWith("public/*.js", { + ignore: undefined, + }); + }); + + test("should work with object { publicFolder } option", () => { + vi.mocked(fg.globSync).mockReturnValue(["public/app.js"]); + + const options: WildcardOptions = { publicFolder: "public/" }; + const result = wildcards("*.js", options); + expect(result).toEqual({ input: ["public/app.js"] }); + expect(fg.globSync).toHaveBeenCalledWith("public/*.js", { + ignore: undefined, + }); + }); + + test("should exclude files matching ignore patterns", () => { + vi.mocked(fg.globSync).mockReturnValue(["app.js", "utils.js"]); + + const options: WildcardOptions = { + ignore: ["**/node_modules/**"], + }; + const result = wildcards("*.js", options); + expect(result).toEqual({ input: ["app.js", "utils.js"] }); + expect(fg.globSync).toHaveBeenCalledWith("*.js", { + ignore: ["**/node_modules/**"], + }); + }); + + test("should work with both publicFolder and ignore in object", () => { + vi.mocked(fg.globSync).mockReturnValue(["src/app.js"]); + + const options: WildcardOptions = { + publicFolder: "src/", + ignore: ["**/*.min.js", "**/dist/**"], + }; + const result = wildcards("*.js", options); + expect(result).toEqual({ input: ["src/app.js"] }); + expect(fg.globSync).toHaveBeenCalledWith("src/*.js", { + ignore: ["**/*.min.js", "**/dist/**"], + }); + }); + + test("should pass ignore to globSync for array input", () => { + vi.mocked(fg.globSync).mockReturnValue(["a.js", "b.js"]); + + const options: WildcardOptions = { + ignore: ["**/node_modules/**"], + }; + const result = wildcards(["*.js", "src/*.js"], options); + expect(result).toEqual({ input: ["a.js", "b.js"] }); + expect(fg.globSync).toHaveBeenCalledWith(["*.js", "src/*.js"], { + ignore: ["**/node_modules/**"], + }); + }); + + test("DEFAULT_IGNORES should be exported and contain expected patterns", () => { + expect(DEFAULT_IGNORES).toBeDefined(); + expect(Array.isArray(DEFAULT_IGNORES)).toBe(true); + expect(DEFAULT_IGNORES).toContain("**/node_modules/**"); + expect(DEFAULT_IGNORES).toContain("**/dist/**"); + expect(DEFAULT_IGNORES).toContain("**/build/**"); + expect(DEFAULT_IGNORES).toContain("**/.next/**"); + expect(DEFAULT_IGNORES).toContain("**/*.min.{js,css}"); + expect(DEFAULT_IGNORES).toContain("**/*.d.ts"); + expect(DEFAULT_IGNORES).toContain("**/__tests__/**"); + expect(DEFAULT_IGNORES).toContain("**/.*"); + }); + + test("should use DEFAULT_IGNORES when no ignore option provided", () => { + vi.mocked(fg.globSync).mockReturnValue(["app.js"]); + + // Test that we can use DEFAULT_IGNORES explicitly + const options: WildcardOptions = { + ignore: DEFAULT_IGNORES, + }; + const result = wildcards("*.js", options); + expect(result).toEqual({ input: ["app.js"] }); + expect(fg.globSync).toHaveBeenCalledWith("*.js", { + ignore: DEFAULT_IGNORES, + }); + }); +}); diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts index 206d48948..09b03861b 100644 --- a/packages/utils/src/index.ts +++ b/packages/utils/src/index.ts @@ -34,12 +34,14 @@ import { run } from "./run.ts"; import { setFileNameMin } from "./setFileNameMin.ts"; import { setPublicFolder } from "./setPublicFolder.ts"; import type { BuildArgsOptions } from "./types.ts"; -import { wildcards } from "./wildcards.ts"; +import type { WildcardOptions } from "./wildcards.ts"; +import { DEFAULT_IGNORES, wildcards } from "./wildcards.ts"; import { writeFile, writeFileAsync } from "./writeFile.ts"; export { buildArgs, compressSingleFile, + DEFAULT_IGNORES, deleteFile, ensureStringContent, getContentFromFiles, @@ -77,3 +79,4 @@ export { export type { BuildArgsOptions }; export type { CompressorResolution } from "./compressor-resolver.ts"; +export type { WildcardOptions }; diff --git a/packages/utils/src/wildcards.ts b/packages/utils/src/wildcards.ts index cd934d7f3..0044d5370 100644 --- a/packages/utils/src/wildcards.ts +++ b/packages/utils/src/wildcards.ts @@ -7,6 +7,34 @@ import os from "node:os"; import fg from "fast-glob"; +/** + * Options for wildcards function + */ +export interface WildcardOptions { + /** + * Path to the public folder + */ + publicFolder?: string; + /** + * Patterns to ignore when matching files + */ + ignore?: string[]; +} + +/** + * Default ignore patterns for common build artifacts and dependencies + */ +export const DEFAULT_IGNORES: string[] = [ + "**/node_modules/**", + "**/dist/**", + "**/build/**", + "**/.next/**", + "**/*.min.{js,css}", + "**/*.d.ts", + "**/__tests__/**", + "**/.*", +]; + /** * Check if the platform is Windows */ @@ -17,27 +45,36 @@ function isWindows() { /** * Handle wildcards in a path, get the real path of each file. * @param input - Path with wildcards - * @param publicFolder - Path to the public folder + * @param options - Options object or string publicFolder for backward compatibility + * @returns Object with resolved file paths */ -export function wildcards(input: string | string[], publicFolder?: string) { +export function wildcards( + input: string | string[], + options?: WildcardOptions | string +) { + const normalizedOptions: WildcardOptions = + typeof options === "string" + ? { publicFolder: options } + : (options ?? {}); + if (Array.isArray(input)) { - return wildcardsArray(input, publicFolder); + return wildcardsArray(input, normalizedOptions); } - return wildcardsString(input, publicFolder); + return wildcardsString(input, normalizedOptions); } /** * Handle wildcards in a path (string only), get the real path of each file. * @param input - Path with wildcards - * @param publicFolder - Path to the public folder + * @param options - Wildcard options */ -function wildcardsString(input: string, publicFolder?: string) { +function wildcardsString(input: string, options: WildcardOptions) { if (!input.includes("*")) { return {}; } - const files = getFilesFromWildcards(input, publicFolder); + const files = getFilesFromWildcards(input, options); const finalPaths = files.filter((path: string) => !path.includes("*")); return { @@ -48,26 +85,24 @@ function wildcardsString(input: string, publicFolder?: string) { /** * Handle wildcards in a path (array only), get the real path of each file. * @param input - Array of paths with wildcards - * @param publicFolder - Path to the public folder + * @param options - Wildcard options */ -function wildcardsArray(input: string[], publicFolder?: string) { - // Convert input paths to patterns with public folder prefix +function wildcardsArray(input: string[], options: WildcardOptions) { const inputWithPublicFolder = input.map((item) => { - const input2 = publicFolder ? publicFolder + item : item; + const input2 = options.publicFolder + ? options.publicFolder + item + : item; return isWindows() ? fg.convertPathToPattern(input2) : input2; }); - // Check if any wildcards exist const hasWildcards = inputWithPublicFolder.some((item) => item.includes("*") ); - // Process paths based on whether wildcards exist const processedPaths = hasWildcards - ? fg.globSync(inputWithPublicFolder) - : input; + ? fg.globSync(inputWithPublicFolder, { ignore: options.ignore }) + : inputWithPublicFolder; - // Filter out any remaining paths with wildcards const finalPaths = processedPaths.filter( (path: string) => !path.includes("*") ); @@ -78,11 +113,14 @@ function wildcardsArray(input: string[], publicFolder?: string) { /** * Get the real path of each file. * @param input - Path with wildcards - * @param publicFolder - Path to the public folder + * @param options - Wildcard options */ -function getFilesFromWildcards(input: string, publicFolder?: string) { - const fullPath = publicFolder ? `${publicFolder}${input}` : input; +function getFilesFromWildcards(input: string, options: WildcardOptions) { + const fullPath = options.publicFolder + ? `${options.publicFolder}${input}` + : input; return fg.globSync( - isWindows() ? fg.convertPathToPattern(fullPath) : fullPath + isWindows() ? fg.convertPathToPattern(fullPath) : fullPath, + { ignore: options.ignore } ); }