mirror of
https://github.com/ivuorinen/gh-action-readme.git
synced 2026-03-18 19:02:17 +00:00
Compare commits
21 Commits
v0.3.1
...
2de27d5cfe
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2de27d5cfe | ||
|
|
9f145dedfe | ||
|
|
78481459f5 | ||
|
|
9e25e0925f | ||
|
|
9c7be8c5d4 | ||
|
|
a75d892747 | ||
| 00044ce374 | |||
|
|
c6426bae19 | ||
|
|
7078aaba50 | ||
| 9bfecc5e6e | |||
| 6291710906 | |||
|
|
fa1ae15a4e | ||
|
|
bc021ab33d | ||
|
|
49faa8f113 | ||
|
|
0333bff9cb | ||
|
|
7ee76d0504 | ||
|
|
db19753586 | ||
|
|
2f6d19a3fc | ||
| ce23f93b74 | |||
| 9534bf9e45 | |||
| 93294f6fd3 |
@@ -34,3 +34,7 @@ tab_width = 2
|
||||
|
||||
[{go.sum,go.mod}]
|
||||
max_line_length = 300
|
||||
|
||||
# Test fixture that intentionally contains trailing whitespace
|
||||
[testdata/yaml-fixtures/configs/permissions/mutation/whitespace-only-value-not-parsed.yaml]
|
||||
trim_trailing_whitespace = false
|
||||
|
||||
8
.github/workflows/ci.yml
vendored
8
.github/workflows/ci.yml
vendored
@@ -9,17 +9,17 @@ jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@4dc6199c7b1a012772edbd06daecab0f50c9053c # v6.1.0
|
||||
uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6.2.0
|
||||
- name: Run golangci-lint
|
||||
uses: golangci/golangci-lint-action@1e7e51e771db61008b38414a730f564565cf7c20 # v9.2.0
|
||||
with:
|
||||
version: v2.7.2
|
||||
- name: Setup Node.js for EditorConfig tools
|
||||
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
|
||||
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
||||
with:
|
||||
node-version: "24"
|
||||
- name: Install EditorConfig tools
|
||||
@@ -28,6 +28,8 @@ jobs:
|
||||
run: eclint check .
|
||||
- name: Run unit tests
|
||||
run: go test ./...
|
||||
- name: Run property-based tests
|
||||
run: make test-property
|
||||
- name: Example Action Readme Generation
|
||||
run: |
|
||||
go run . gen testdata/example-action --output example-README.md
|
||||
|
||||
8
.github/workflows/codeql.yml
vendored
8
.github/workflows/codeql.yml
vendored
@@ -29,20 +29,20 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@5d4e8d1aca955e8d8589aabd499c5cae939e33c7 # v4.31.9
|
||||
uses: github/codeql-action/init@b20883b0cd1f46c72ae0ba6d1090936928f9fa30 # v4.32.0
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
queries: security-and-quality
|
||||
|
||||
- name: Autobuild
|
||||
uses: github/codeql-action/autobuild@5d4e8d1aca955e8d8589aabd499c5cae939e33c7 # v4.31.9
|
||||
uses: github/codeql-action/autobuild@b20883b0cd1f46c72ae0ba6d1090936928f9fa30 # v4.32.0
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@5d4e8d1aca955e8d8589aabd499c5cae939e33c7 # v4.31.9
|
||||
uses: github/codeql-action/analyze@b20883b0cd1f46c72ae0ba6d1090936928f9fa30 # v4.32.0
|
||||
with:
|
||||
category: "/language:${{matrix.language}}"
|
||||
|
||||
4
.github/workflows/commitlint.yml
vendored
4
.github/workflows/commitlint.yml
vendored
@@ -18,12 +18,12 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
|
||||
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
||||
with:
|
||||
node-version: "24"
|
||||
|
||||
|
||||
2
.github/workflows/pr-lint.yml
vendored
2
.github/workflows/pr-lint.yml
vendored
@@ -31,4 +31,4 @@ jobs:
|
||||
steps:
|
||||
- name: Run PR Lint
|
||||
# https://github.com/ivuorinen/actions
|
||||
uses: ivuorinen/actions/pr-lint@fb25736f7e7a438979c11764e9fe6a100278b4c5 # v2026.01.01
|
||||
uses: ivuorinen/actions/pr-lint@f98ae7cd7d0feb1f9d6b01de0addbb11414cfc73 # v2026.01.21
|
||||
|
||||
8
.github/workflows/release.yml
vendored
8
.github/workflows/release.yml
vendored
@@ -18,17 +18,17 @@ jobs:
|
||||
id-token: write
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@4dc6199c7b1a012772edbd06daecab0f50c9053c # v6.1.0
|
||||
uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6.2.0
|
||||
with:
|
||||
cache: true
|
||||
|
||||
- name: Set up Node.js (for cosign)
|
||||
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
|
||||
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
||||
with:
|
||||
node-version: "24"
|
||||
|
||||
@@ -38,7 +38,7 @@ jobs:
|
||||
cosign-release: "v2.4.0"
|
||||
|
||||
- name: Install syft
|
||||
uses: anchore/sbom-action/download-syft@a930d0ac434e3182448fe678398ba5713717112a # v0.21.0
|
||||
uses: anchore/sbom-action/download-syft@62ad5284b8ced813296287a0b63906cb364b73ee # v0.22.0
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
|
||||
|
||||
18
.github/workflows/security.yml
vendored
18
.github/workflows/security.yml
vendored
@@ -25,12 +25,12 @@ jobs:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@4dc6199c7b1a012772edbd06daecab0f50c9053c # v6.1.0
|
||||
uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6.2.0
|
||||
with:
|
||||
go-version-file: "go.mod"
|
||||
check-latest: true
|
||||
@@ -49,7 +49,7 @@ jobs:
|
||||
security-events: write
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
@@ -63,7 +63,7 @@ jobs:
|
||||
severity: "CRITICAL,HIGH,MEDIUM"
|
||||
|
||||
- name: Upload Trivy scan results to GitHub Security tab
|
||||
uses: github/codeql-action/upload-sarif@5d4e8d1aca955e8d8589aabd499c5cae939e33c7 # v4.31.9
|
||||
uses: github/codeql-action/upload-sarif@b20883b0cd1f46c72ae0ba6d1090936928f9fa30 # v4.32.0
|
||||
if: always()
|
||||
with:
|
||||
sarif_file: "trivy-results.sarif"
|
||||
@@ -84,7 +84,7 @@ jobs:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
fetch-depth: 0 # Full history for gitleaks
|
||||
|
||||
@@ -103,12 +103,12 @@ jobs:
|
||||
if: github.event_name != 'pull_request' # Skip on PRs to avoid building images unnecessarily
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@4dc6199c7b1a012772edbd06daecab0f50c9053c # v6.1.0
|
||||
uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6.2.0
|
||||
with:
|
||||
go-version-file: "go.mod"
|
||||
check-latest: true
|
||||
@@ -142,7 +142,7 @@ jobs:
|
||||
output: "trivy-docker-results.sarif"
|
||||
|
||||
- name: Upload Docker Trivy scan results
|
||||
uses: github/codeql-action/upload-sarif@5d4e8d1aca955e8d8589aabd499c5cae939e33c7 # v4.31.9
|
||||
uses: github/codeql-action/upload-sarif@b20883b0cd1f46c72ae0ba6d1090936928f9fa30 # v4.32.0
|
||||
if: always()
|
||||
with:
|
||||
sarif_file: "trivy-docker-results.sarif"
|
||||
@@ -156,7 +156,7 @@ jobs:
|
||||
if: github.event_name == 'pull_request'
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
|
||||
2
.github/workflows/stale.yml
vendored
2
.github/workflows/stale.yml
vendored
@@ -23,4 +23,4 @@ jobs:
|
||||
issues: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- uses: ivuorinen/actions/stale@fb25736f7e7a438979c11764e9fe6a100278b4c5 # v2026.01.01
|
||||
- uses: ivuorinen/actions/stale@f98ae7cd7d0feb1f9d6b01de0addbb11414cfc73 # v2026.01.21
|
||||
|
||||
4
.github/workflows/sync-labels.yml
vendored
4
.github/workflows/sync-labels.yml
vendored
@@ -35,9 +35,9 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: ⤵️ Checkout Repository
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: ⤵️ Sync Latest Labels Definitions
|
||||
uses: ivuorinen/actions/sync-labels@fb25736f7e7a438979c11764e9fe6a100278b4c5 # v2026.01.01
|
||||
uses: ivuorinen/actions/sync-labels@f98ae7cd7d0feb1f9d6b01de0addbb11414cfc73 # v2026.01.21
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -38,3 +38,4 @@ coverage.*
|
||||
# Other
|
||||
/megalinter-reports/
|
||||
cr.txt
|
||||
pr.txt
|
||||
|
||||
@@ -2,6 +2,20 @@
|
||||
# yaml-language-server: $schema=https://golangci-lint.run/jsonschema/golangci.jsonschema.json
|
||||
version: "2"
|
||||
|
||||
# golangci-lint configuration
|
||||
# Aligned with SonarCloud "Sonar way" quality gate
|
||||
# https://docs.sonarsource.com/sonarqube-cloud/standards/managing-quality-gates/
|
||||
#
|
||||
# Key alignments:
|
||||
# - gosec: Aligns with Security Rating A requirement (no vulnerabilities)
|
||||
# - gocyclo (min: 10): Stricter than SonarCloud (not enforced)
|
||||
# - dupl: Aligns with duplicated lines density <= 3%
|
||||
# - lll (120 chars): Stricter than SonarCloud (not enforced)
|
||||
# - Code coverage: See Makefile target 'test-coverage-check' (>= 60%, goal: 80% for new code)
|
||||
#
|
||||
# SonarCloud focuses on new code (last 30 days), local linting checks entire codebase
|
||||
# Local standards are intentionally stricter in some areas (complexity, line length)
|
||||
|
||||
run:
|
||||
timeout: 5m
|
||||
go: "1.24"
|
||||
|
||||
@@ -57,9 +57,6 @@ archives:
|
||||
- LICENSE*
|
||||
- CHANGELOG.md
|
||||
- docs/*.md
|
||||
- templates/*.tmpl
|
||||
- templates/themes/**/*.tmpl
|
||||
- templates/themes/**/*.adoc
|
||||
- schemas/*.json
|
||||
|
||||
checksum:
|
||||
@@ -199,7 +196,6 @@ dockers_v2:
|
||||
- linux/amd64
|
||||
- linux/arm64
|
||||
extra_files:
|
||||
- templates
|
||||
- schemas
|
||||
labels:
|
||||
org.opencontainers.image.created: "{{.Date}}"
|
||||
|
||||
@@ -27,7 +27,7 @@ repos:
|
||||
|
||||
# YAML formatting with yamlfmt (replaces yamllint for formatting)
|
||||
- repo: https://github.com/google/yamlfmt
|
||||
rev: v0.20.0
|
||||
rev: v0.21.0
|
||||
hooks:
|
||||
- id: yamlfmt
|
||||
exclude: "^testdata/"
|
||||
@@ -42,7 +42,7 @@ repos:
|
||||
|
||||
# EditorConfig checking
|
||||
- repo: https://github.com/editorconfig-checker/editorconfig-checker
|
||||
rev: v3.6.0
|
||||
rev: v3.6.1
|
||||
hooks:
|
||||
- id: editorconfig-checker
|
||||
alias: ec
|
||||
@@ -72,7 +72,7 @@ repos:
|
||||
|
||||
# Commit message linting
|
||||
- repo: https://github.com/alessandrojcm/commitlint-pre-commit-hook
|
||||
rev: v9.23.0
|
||||
rev: v9.24.0
|
||||
hooks:
|
||||
- id: commitlint
|
||||
stages: [commit-msg]
|
||||
|
||||
1
.serena/.gitignore
vendored
Normal file
1
.serena/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/cache
|
||||
85
.serena/project.yml
Normal file
85
.serena/project.yml
Normal file
@@ -0,0 +1,85 @@
|
||||
---
|
||||
# list of languages for which language servers are started; choose from:
|
||||
# al bash clojure cpp csharp csharp_omnisharp
|
||||
# dart elixir elm erlang fortran go
|
||||
# haskell java julia kotlin lua markdown
|
||||
# nix perl php python python_jedi r
|
||||
# rego ruby ruby_solargraph rust scala swift
|
||||
# terraform typescript typescript_vts yaml zig
|
||||
# Note:
|
||||
# - For C, use cpp
|
||||
# - For JavaScript, use typescript
|
||||
# Special requirements:
|
||||
# - csharp: Requires the presence of a .sln file in the project folder.
|
||||
# When using multiple languages, the first language server that supports a given file will be used for that file.
|
||||
# The first language is the default language and the respective language server will be used as a fallback.
|
||||
# Note that when using the JetBrains backend, language servers are not used and this list is correspondingly ignored.
|
||||
languages:
|
||||
- go
|
||||
|
||||
# the encoding used by text files in the project
|
||||
# For a list of possible encodings, see https://docs.python.org/3.11/library/codecs.html#standard-encodings
|
||||
encoding: "utf-8"
|
||||
|
||||
# whether to use the project's gitignore file to ignore files
|
||||
# Added on 2025-04-07
|
||||
ignore_all_files_in_gitignore: true
|
||||
|
||||
# list of additional paths to ignore
|
||||
# same syntax as gitignore, so you can use * and **
|
||||
# Was previously called `ignored_dirs`, please update your config if you are using that.
|
||||
# Added (renamed) on 2025-04-07
|
||||
ignored_paths: []
|
||||
|
||||
# whether the project is in read-only mode
|
||||
# If set to true, all editing tools will be disabled and attempts to use them will result in an error
|
||||
# Added on 2025-04-18
|
||||
read_only: false
|
||||
|
||||
# list of tool names to exclude. We recommend not excluding any tools, see the readme for more details.
|
||||
# Below is the complete list of tools for convenience.
|
||||
# To make sure you have the latest list of tools, and to view their descriptions,
|
||||
# execute `uv run scripts/print_tool_overview.py`.
|
||||
#
|
||||
# * `activate_project`: Activates a project by name.
|
||||
# * `check_onboarding_performed`: Checks whether project onboarding was already performed.
|
||||
# * `create_text_file`: Creates/overwrites a file in the project directory.
|
||||
# * `delete_lines`: Deletes a range of lines within a file.
|
||||
# * `delete_memory`: Deletes a memory from Serena's project-specific memory store.
|
||||
# * `execute_shell_command`: Executes a shell command.
|
||||
# * `find_referencing_code_snippets`: Finds code snippets in which the symbol at the given location is referenced.
|
||||
# * `find_referencing_symbols`: Finds symbols that reference the symbol at the given location (optionally filtered by type).
|
||||
# * `find_symbol`: Performs a global (or local) search for symbols with/containing a given name/substring (optionally filtered by type).
|
||||
# * `get_current_config`: Prints the current configuration of the agent, including the active and available projects, tools, contexts, and modes.
|
||||
# * `get_symbols_overview`: Gets an overview of the top-level symbols defined in a given file.
|
||||
# * `initial_instructions`: Gets the initial instructions for the current project.
|
||||
# Should only be used in settings where the system prompt cannot be set,
|
||||
# e.g. in clients you have no control over, like Claude Desktop.
|
||||
# * `insert_after_symbol`: Inserts content after the end of the definition of a given symbol.
|
||||
# * `insert_at_line`: Inserts content at a given line in a file.
|
||||
# * `insert_before_symbol`: Inserts content before the beginning of the definition of a given symbol.
|
||||
# * `list_dir`: Lists files and directories in the given directory (optionally with recursion).
|
||||
# * `list_memories`: Lists memories in Serena's project-specific memory store.
|
||||
# * `onboarding`: Performs onboarding (identifying the project structure and essential tasks, e.g. for testing or building).
|
||||
# * `prepare_for_new_conversation`: Provides instructions for preparing for a new conversation (in order to continue with the necessary context).
|
||||
# * `read_file`: Reads a file within the project directory.
|
||||
# * `read_memory`: Reads the memory with the given name from Serena's project-specific memory store.
|
||||
# * `remove_project`: Removes a project from the Serena configuration.
|
||||
# * `replace_lines`: Replaces a range of lines within a file with new content.
|
||||
# * `replace_symbol_body`: Replaces the full definition of a symbol.
|
||||
# * `restart_language_server`: Restarts the language server, may be necessary when edits not through Serena happen.
|
||||
# * `search_for_pattern`: Performs a search for a pattern in the project.
|
||||
# * `summarize_changes`: Provides instructions for summarizing the changes made to the codebase.
|
||||
# * `switch_modes`: Activates modes by providing a list of their names
|
||||
# * `think_about_collected_information`: Thinking tool for pondering the completeness of collected information.
|
||||
# * `think_about_task_adherence`: Thinking tool for determining whether the agent is still on track with the current task.
|
||||
# * `think_about_whether_you_are_done`: Thinking tool for determining whether the task is truly completed.
|
||||
# * `write_memory`: Writes a named memory (for future reference) to Serena's project-specific memory store.
|
||||
excluded_tools: []
|
||||
|
||||
# initial prompt for the project. It will always be given to the LLM upon activating the project
|
||||
# (contrary to the memories, which are loaded on demand).
|
||||
initial_prompt: ""
|
||||
|
||||
project_name: "gh-action-readme"
|
||||
included_optional_tools: []
|
||||
@@ -10,12 +10,10 @@ ARG TARGETPLATFORM
|
||||
# Copy the binary from the build context (platform-specific)
|
||||
COPY $TARGETPLATFORM/gh-action-readme /usr/local/bin/gh-action-readme
|
||||
|
||||
# Copy templates and schemas
|
||||
COPY templates /usr/local/share/gh-action-readme/templates
|
||||
# Copy schemas (templates are embedded in the binary via go:embed)
|
||||
COPY schemas /usr/local/share/gh-action-readme/schemas
|
||||
|
||||
# Set environment variables for template paths
|
||||
ENV GH_ACTION_README_TEMPLATE_PATH=/usr/local/share/gh-action-readme/templates
|
||||
# Set environment variable for schema path
|
||||
ENV GH_ACTION_README_SCHEMA_PATH=/usr/local/share/gh-action-readme/schemas
|
||||
|
||||
# Set the binary as entrypoint
|
||||
|
||||
80
Makefile
80
Makefile
@@ -1,10 +1,17 @@
|
||||
.PHONY: help test test-coverage test-coverage-html lint build run example \
|
||||
clean readme config-verify security vulncheck audit trivy gitleaks \
|
||||
.PHONY: help test test-quick test-coverage test-coverage-html test-coverage-check \
|
||||
test-mutation test-mutation-parser test-mutation-validation \
|
||||
test-property test-property-validation test-property-parser \
|
||||
lint build run example clean readme config-verify security vulncheck audit trivy gitleaks \
|
||||
editorconfig editorconfig-fix format devtools pre-commit-install pre-commit-update \
|
||||
deps-check deps-update deps-update-all
|
||||
|
||||
all: help
|
||||
|
||||
# Coverage threshold (align with SonarCloud)
|
||||
# Note: SonarCloud checks NEW code coverage (≥80%), this checks overall coverage
|
||||
# Current overall coverage: 72.9% - working towards 80% target
|
||||
COVERAGE_THRESHOLD := 72.0
|
||||
|
||||
help: ## Show this help message
|
||||
@echo "GitHub Action README Generator - Available Make Targets:"
|
||||
@echo ""
|
||||
@@ -22,7 +29,20 @@ help: ## Show this help message
|
||||
@echo " make deps-update # Update dependencies interactively"
|
||||
@echo " make security # Run all security scans"
|
||||
|
||||
test: ## Run all tests
|
||||
test: ## Run all tests (standard and property-based)
|
||||
@echo "Running standard tests..."
|
||||
@go test ./...
|
||||
@echo ""
|
||||
@echo "Running property-based tests..."
|
||||
@$(MAKE) test-property
|
||||
@echo ""
|
||||
@echo "✅ All tests (standard + property) completed successfully!"
|
||||
@echo ""
|
||||
@echo "Note: Mutation tests require go-mutesting (compatible with Go 1.22/1.23 only)."
|
||||
@echo " Run 'make test-mutation' if you have a compatible Go version."
|
||||
@echo " Run 'make test-quick' for fast iteration (unit tests only)."
|
||||
|
||||
test-quick: ## Run only standard unit tests (fast)
|
||||
go test ./...
|
||||
|
||||
test-coverage: ## Run tests with coverage and display in CLI
|
||||
@@ -54,6 +74,60 @@ test-coverage-html: test-coverage ## Generate HTML coverage report and open in b
|
||||
echo "Open coverage.html in your browser to view detailed coverage"; \
|
||||
fi
|
||||
|
||||
test-coverage-check: ## Run tests with coverage check (overall >= 72%)
|
||||
@command -v bc >/dev/null 2>&1 || { \
|
||||
echo "❌ bc command not found. Please install bc (e.g., apt-get install bc, brew install bc)"; \
|
||||
exit 1; \
|
||||
}
|
||||
@echo "Running tests with coverage check..."
|
||||
@go test -cover -coverprofile=coverage.out ./...
|
||||
@total=$$(go tool cover -func=coverage.out | grep total | awk '{print $$3}' | sed 's/%//'); \
|
||||
if [ $$(echo "$$total < $(COVERAGE_THRESHOLD)" | bc) -eq 1 ]; then \
|
||||
echo "❌ Coverage $$total% is below threshold $(COVERAGE_THRESHOLD)%"; \
|
||||
exit 1; \
|
||||
else \
|
||||
echo "✅ Coverage $$total% meets threshold $(COVERAGE_THRESHOLD)%"; \
|
||||
fi
|
||||
|
||||
.PHONY: test-mutation test-mutation-parser test-mutation-validation
|
||||
|
||||
test-mutation: test-mutation-parser test-mutation-validation ## Run all mutation tests
|
||||
|
||||
test-mutation-parser: ## Run mutation tests on parser (permission parsing)
|
||||
@echo "Running mutation tests on parser..."
|
||||
@command -v go-mutesting >/dev/null 2>&1 || { \
|
||||
echo "❌ go-mutesting not found. Installing..."; \
|
||||
go install github.com/zimmski/go-mutesting/cmd/go-mutesting@latest; \
|
||||
}
|
||||
@go-mutesting --do-not-remove internal/parser.go -- \
|
||||
go test -v ./internal -run "TestParse.*Permissions|TestMerge.*Permissions|TestProcess.*Permission"
|
||||
|
||||
test-mutation-validation: ## Run mutation tests on validation (version and strings)
|
||||
@echo "Running mutation tests on validation..."
|
||||
@command -v go-mutesting >/dev/null 2>&1 || { \
|
||||
echo "❌ go-mutesting not found. Installing..."; \
|
||||
go install github.com/zimmski/go-mutesting/cmd/go-mutesting@latest; \
|
||||
}
|
||||
@echo "Testing version validation..."
|
||||
@go-mutesting --do-not-remove internal/validation/validation.go -- \
|
||||
go test -v ./internal/validation -run "TestIsCommitSHA|TestIsSemanticVersion|TestIsVersionPinned"
|
||||
@echo ""
|
||||
@echo "Testing string validation..."
|
||||
@go-mutesting --do-not-remove internal/validation/strings.go -- \
|
||||
go test -v ./internal/validation -run "TestParseGitHubURL|TestSanitize|TestFormat|TestClean"
|
||||
|
||||
.PHONY: test-property test-property-validation test-property-parser
|
||||
|
||||
test-property: test-property-validation test-property-parser ## Run all property-based tests
|
||||
|
||||
test-property-validation: ## Run property tests on validation (strings)
|
||||
@echo "Running property tests on validation..."
|
||||
@go test -v ./internal/validation -run ".*Properties" -timeout 30s
|
||||
|
||||
test-property-parser: ## Run property tests on parser (permission merging)
|
||||
@echo "Running property tests on parser..."
|
||||
@go test -v ./internal -run ".*Properties" -timeout 30s
|
||||
|
||||
lint: editorconfig ## Run all linters via pre-commit
|
||||
@echo "Running all linters via pre-commit..."
|
||||
@command -v pre-commit >/dev/null 2>&1 || \
|
||||
|
||||
31
README.md
31
README.md
@@ -11,6 +11,13 @@
|
||||
[](https://github.com/ivuorinen/gh-action-readme/actions/workflows/security.yml)
|
||||
[](https://github.com/ivuorinen/gh-action-readme/actions/workflows/codeql.yml)
|
||||
|
||||
[](https://sonarcloud.io/summary/new_code?id=ivuorinen_gh-action-readme)
|
||||
[](https://sonarcloud.io/summary/new_code?id=ivuorinen_gh-action-readme)
|
||||
[](https://sonarcloud.io/summary/new_code?id=ivuorinen_gh-action-readme)
|
||||
|
||||
[](https://sonarcloud.io/summary/new_code?id=ivuorinen_gh-action-readme)
|
||||
[](https://sonarcloud.io/summary/new_code?id=ivuorinen_gh-action-readme)
|
||||
|
||||
</div>
|
||||
|
||||
> **The definitive CLI tool for generating beautiful documentation from GitHub Actions `action.yml` files**
|
||||
@@ -28,6 +35,28 @@ Transform your GitHub Actions into professional documentation with multiple them
|
||||
- 📁 **Flexible Targeting** - Directory/file arguments, custom output filenames
|
||||
- 🛡️ **Thread Safe** - Race condition protection, concurrent processing ready
|
||||
|
||||
## 🛡️ Quality Gates
|
||||
|
||||
This project enforces quality standards aligned with [SonarCloud "Sonar way"](https://docs.sonarsource.com/sonarqube-cloud/standards/managing-quality-gates/):
|
||||
|
||||
| Metric | Threshold |
|
||||
| ---------------------- | ------------------- |
|
||||
| Code Coverage | ≥ 80% (new code) |
|
||||
| Duplicated Lines | ≤ 3% (new code) |
|
||||
| Security Rating | A (no issues) |
|
||||
| Reliability Rating | A (no bugs) |
|
||||
| Maintainability Rating | A (tech debt ≤ 5%) |
|
||||
|
||||
**Local Development Checks:**
|
||||
|
||||
```bash
|
||||
make lint # Run all linters (gosec, dupl, gocyclo, etc.)
|
||||
make test-coverage-check # Verify coverage threshold
|
||||
make security # Security scans (gosec, trivy, gitleaks)
|
||||
```
|
||||
|
||||
Local linting enforces additional standards including cyclomatic complexity ≤ 10 and line length ≤ 120 characters.
|
||||
|
||||
## 🚀 Quick Start
|
||||
|
||||
### Installation
|
||||
@@ -164,7 +193,7 @@ gh-action-readme config init
|
||||
gh-action-readme gen --recursive --theme github --output-dir docs/
|
||||
|
||||
# Custom themes
|
||||
cp -r templates/themes/github templates/themes/custom
|
||||
cp -r templates_embed/templates/themes/github templates_embed/templates/themes/custom
|
||||
gh-action-readme gen --theme custom
|
||||
```
|
||||
|
||||
|
||||
@@ -87,8 +87,6 @@ const (
|
||||
const (
|
||||
// ContextKeyError is used as a key for error information in context maps.
|
||||
ContextKeyError = "error"
|
||||
// ContextKeyTheme is used as a key for theme information.
|
||||
ContextKeyTheme = "theme"
|
||||
// ContextKeyConfig is used as a key for configuration information.
|
||||
ContextKeyConfig = "config"
|
||||
)
|
||||
@@ -125,6 +123,23 @@ func GetSupportedThemes() []string {
|
||||
return themes
|
||||
}
|
||||
|
||||
// supportedOutputFormats lists all available output format names (unexported to prevent modification).
|
||||
var supportedOutputFormats = []string{
|
||||
OutputFormatMarkdown,
|
||||
OutputFormatHTML,
|
||||
OutputFormatJSON,
|
||||
OutputFormatASCIIDoc,
|
||||
}
|
||||
|
||||
// GetSupportedOutputFormats returns a copy of the supported output format names.
|
||||
// Returns a new slice to prevent external modification of the internal list.
|
||||
func GetSupportedOutputFormats() []string {
|
||||
formats := make([]string, len(supportedOutputFormats))
|
||||
copy(formats, supportedOutputFormats)
|
||||
|
||||
return formats
|
||||
}
|
||||
|
||||
// Template placeholder constants for Git repository information.
|
||||
const (
|
||||
// DefaultOrgPlaceholder is the default organization placeholder.
|
||||
@@ -152,6 +167,8 @@ const (
|
||||
ConfigKeyRepository = "repository"
|
||||
// ConfigKeyVersion is the version config key.
|
||||
ConfigKeyVersion = "version"
|
||||
// ConfigKeyUseDefaultBranch is the configuration key for use default branch behavior.
|
||||
ConfigKeyUseDefaultBranch = "use_default_branch"
|
||||
|
||||
// Template Configuration
|
||||
// ConfigKeyTheme is the configuration key for theme.
|
||||
@@ -182,6 +199,8 @@ const (
|
||||
ConfigKeyVerbose = "verbose"
|
||||
// ConfigKeyQuiet is the configuration key for quiet mode.
|
||||
ConfigKeyQuiet = "quiet"
|
||||
// ConfigKeyIgnoredDirectories is the configuration key for ignored directories during discovery.
|
||||
ConfigKeyIgnoredDirectories = "ignored_directories"
|
||||
|
||||
// GitHub Integration
|
||||
// ConfigKeyGitHubToken is the configuration key for GitHub token.
|
||||
@@ -261,6 +280,26 @@ func GetConfigSearchPaths() []string {
|
||||
return paths
|
||||
}
|
||||
|
||||
// defaultIgnoredDirectories lists directories to ignore during file discovery.
|
||||
var defaultIgnoredDirectories = []string{
|
||||
DirGit, DirGitHub, DirGitLab, DirSVN, // VCS
|
||||
DirNodeModules, DirBowerComponents, // JavaScript
|
||||
DirVendor, // Go/PHP
|
||||
DirVenvDot, DirVenv, DirEnv, DirTox, DirPycache, // Python
|
||||
DirDist, DirBuild, DirTarget, DirOut, // Build outputs
|
||||
DirIdea, DirVscode, // IDEs
|
||||
DirCache, DirTmpDot, DirTmp, // Cache/temp
|
||||
}
|
||||
|
||||
// GetDefaultIgnoredDirectories returns a copy of the default ignored directory names.
|
||||
// Returns a new slice to prevent external modification of the internal list.
|
||||
func GetDefaultIgnoredDirectories() []string {
|
||||
dirs := make([]string, len(defaultIgnoredDirectories))
|
||||
copy(dirs, defaultIgnoredDirectories)
|
||||
|
||||
return dirs
|
||||
}
|
||||
|
||||
// Output format constants.
|
||||
const (
|
||||
// OutputFormatMarkdown is the Markdown output format.
|
||||
@@ -317,6 +356,46 @@ const (
|
||||
EnvPrefix = "GH_ACTION_README"
|
||||
)
|
||||
|
||||
// Directory names commonly ignored during file discovery.
|
||||
// These constants are used to exclude build artifacts, dependencies,
|
||||
// version control, and temporary files from action file discovery.
|
||||
const (
|
||||
// Version Control System directories
|
||||
// DirGit = ".git" (already defined above in "Directory and path constants").
|
||||
DirGitHub = ".github"
|
||||
DirGitLab = ".gitlab"
|
||||
DirSVN = ".svn"
|
||||
|
||||
// JavaScript/Node.js dependencies.
|
||||
DirNodeModules = "node_modules"
|
||||
DirBowerComponents = "bower_components"
|
||||
|
||||
// Package manager vendor directories.
|
||||
DirVendor = "vendor"
|
||||
|
||||
// Python virtual environments and cache.
|
||||
DirVenv = "venv"
|
||||
DirVenvDot = ".venv"
|
||||
DirEnv = "env"
|
||||
DirTox = ".tox"
|
||||
DirPycache = "__pycache__"
|
||||
|
||||
// Build output directories.
|
||||
DirDist = "dist"
|
||||
DirBuild = "build"
|
||||
DirTarget = "target"
|
||||
DirOut = "out"
|
||||
|
||||
// IDE configuration directories.
|
||||
DirIdea = ".idea"
|
||||
DirVscode = ".vscode"
|
||||
|
||||
// Cache and temporary directories.
|
||||
DirCache = ".cache"
|
||||
DirTmp = "tmp"
|
||||
DirTmpDot = ".tmp"
|
||||
)
|
||||
|
||||
// Git constants.
|
||||
const (
|
||||
// GitCommand is the git command name.
|
||||
@@ -347,6 +426,16 @@ const (
|
||||
ActionTypeMinimal = "minimal"
|
||||
)
|
||||
|
||||
// GitHub Actions runner constants.
|
||||
const (
|
||||
// RunnerUbuntuLatest is the latest Ubuntu runner.
|
||||
RunnerUbuntuLatest = "ubuntu-latest"
|
||||
// RunnerWindowsLatest is the latest Windows runner.
|
||||
RunnerWindowsLatest = "windows-latest"
|
||||
// RunnerMacosLatest is the latest macOS runner.
|
||||
RunnerMacosLatest = "macos-latest"
|
||||
)
|
||||
|
||||
// Programming language identifier constants.
|
||||
const (
|
||||
// LangJavaScriptTypeScript is the JavaScript/TypeScript language identifier.
|
||||
@@ -485,6 +574,16 @@ const (
|
||||
FlagOutput = "output"
|
||||
// FlagRecursive is the recursive flag name.
|
||||
FlagRecursive = "recursive"
|
||||
// FlagIgnoreDirs is the ignore-dirs flag name.
|
||||
FlagIgnoreDirs = "ignore-dirs"
|
||||
// FlagCI is the CI mode flag name.
|
||||
FlagCI = "ci"
|
||||
|
||||
// CommandPin is the pin command name.
|
||||
CommandPin = "pin"
|
||||
|
||||
// CacheStatsKeyDir is the cache stats key for directory.
|
||||
CacheStatsKeyDir = "cache_dir"
|
||||
)
|
||||
|
||||
// Field names for validation.
|
||||
@@ -572,11 +671,21 @@ const (
|
||||
// ErrFailedToAccessCache is the failed to access cache error.
|
||||
ErrFailedToAccessCache = "Failed to access cache: %v"
|
||||
// ErrNoActionFilesFound is the no action files found error.
|
||||
ErrNoActionFilesFound = "No action files found"
|
||||
ErrNoActionFilesFound = "no action files found"
|
||||
// ErrFailedToGetCurrentFilePath is the failed to get current file path error.
|
||||
ErrFailedToGetCurrentFilePath = "failed to get current file path"
|
||||
// ErrFailedToLoadActionFixture is the failed to load action fixture error.
|
||||
ErrFailedToLoadActionFixture = "failed to load action fixture %s: %v"
|
||||
// ErrFailedToApplyUpdatesWrapped is the failed to apply updates error with wrapping.
|
||||
ErrFailedToApplyUpdatesWrapped = "failed to apply updates: %w"
|
||||
// ErrFailedToDiscoverActionFiles is the failed to discover action files error with wrapping.
|
||||
ErrFailedToDiscoverActionFiles = "failed to discover action files: %w"
|
||||
// ErrPathTraversal is the path traversal attempt error.
|
||||
ErrPathTraversal = "path traversal detected: output path '%s' attempts to escape output directory '%s'"
|
||||
// ErrInvalidOutputPath is the invalid output path error.
|
||||
ErrInvalidOutputPath = "invalid output path: %w"
|
||||
// ErrFailedToResolveOutputPath is the failed to resolve output path error with wrapping.
|
||||
ErrFailedToResolveOutputPath = "failed to resolve output path: %w"
|
||||
)
|
||||
|
||||
// Common message templates.
|
||||
@@ -589,6 +698,120 @@ const (
|
||||
MsgConfigurationExportedTo = "Configuration exported to: %s"
|
||||
)
|
||||
|
||||
// Test command names - used across multiple test files.
|
||||
const (
|
||||
TestCmdGen = "gen"
|
||||
TestCmdConfig = "config"
|
||||
TestCmdValidate = "validate"
|
||||
TestCmdDeps = "deps"
|
||||
TestCmdShow = "show"
|
||||
TestCmdList = "list"
|
||||
)
|
||||
|
||||
// Test file paths and names - used across multiple test files.
|
||||
const (
|
||||
TestTmpDir = "/tmp"
|
||||
TestTmpActionFile = "/tmp/action.yml"
|
||||
TestErrorScenarioOldDeps = "error-scenarios/action-with-old-deps.yml"
|
||||
TestErrorScenarioMissing = "error-scenarios/missing-required-fields.yml"
|
||||
TestErrorScenarioInvalid = "error-scenarios/invalid-yaml-syntax.yml"
|
||||
)
|
||||
|
||||
// TestMinimalAction is the minimal action YAML content for testing.
|
||||
const TestMinimalAction = "name: Test\ndescription: Test\nruns:\n using: composite\n steps: []"
|
||||
|
||||
// TestScenarioNoDeps is the common test scenario description for actions with no dependencies.
|
||||
const TestScenarioNoDeps = "handles action with no dependencies"
|
||||
|
||||
// Test messages and error strings - used in output tests.
|
||||
const (
|
||||
TestMsgFileNotFound = "File not found"
|
||||
TestMsgInvalidYAML = "Invalid YAML"
|
||||
TestMsgQuietSuppressOutput = "quiet mode suppresses output"
|
||||
TestMsgNoOutputInQuiet = "Expected no output in quiet mode, got %q"
|
||||
TestMsgVerifyPermissions = "Verify permissions"
|
||||
TestMsgSuggestions = "Suggestions"
|
||||
TestMsgDetails = "Details"
|
||||
TestMsgCheckFilePath = "Check the file path"
|
||||
TestMsgTryAgain = "Try again"
|
||||
TestMsgProcessingStarted = "Processing started"
|
||||
TestMsgOperationCompleted = "Operation completed"
|
||||
TestMsgOutputMissingEmoji = "Output missing error emoji: %q"
|
||||
)
|
||||
|
||||
// Test scenario names - used in output tests.
|
||||
const (
|
||||
TestScenarioColorEnabled = "with color enabled"
|
||||
TestScenarioColorDisabled = "with color disabled"
|
||||
TestScenarioQuietEnabled = "quiet mode enabled"
|
||||
TestScenarioQuietDisabled = "quiet mode disabled"
|
||||
)
|
||||
|
||||
// Test URLs and paths - used in output tests.
|
||||
const (
|
||||
TestURLHelp = "https://example.com/help"
|
||||
TestKeyFile = "file"
|
||||
TestKeyPath = "path"
|
||||
)
|
||||
|
||||
// Test wizard inputs and prompts - used in wizard tests.
|
||||
const (
|
||||
TestWizardInputYes = "y\n"
|
||||
TestWizardInputNo = "n\n"
|
||||
TestWizardInputYesYes = "y\ny\n"
|
||||
TestWizardInputTwo = "2\n"
|
||||
TestWizardInputTripleNL = "\n\n\n"
|
||||
TestWizardInputDoubleNL = "\n\n"
|
||||
TestWizardPromptContinue = "Continue?"
|
||||
TestWizardPromptEnter = "Enter value"
|
||||
)
|
||||
|
||||
// Test repository and organization names - used in wizard tests.
|
||||
const (
|
||||
TestOrgName = "testorg"
|
||||
TestRepoName = "testrepo"
|
||||
TestValue = "test"
|
||||
TestVersion = "v1.0.0"
|
||||
TestDocsPath = "./docs"
|
||||
)
|
||||
|
||||
// Test assertion messages - used in wizard tests.
|
||||
const (
|
||||
TestAssertTheme = "Theme = %q, want %q"
|
||||
)
|
||||
|
||||
// Test dependency actions - used in updater tests.
|
||||
const (
|
||||
TestActionCheckoutV4 = "actions/checkout@v4"
|
||||
TestActionCheckoutPinned = "actions/checkout@abc123 # v4.1.1"
|
||||
TestActionCheckoutFullSHA = "actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7"
|
||||
TestActionCheckoutSHA = "692973e3d937129bcbf40652eb9f2f61becf3332"
|
||||
TestActionCheckoutVersion = "v4.1.7"
|
||||
TestCacheKey = "test-key"
|
||||
TestUpdateTypePatch = "patch"
|
||||
TestDepsSimpleCheckoutFile = "dependencies/simple-test-checkout.yml"
|
||||
)
|
||||
|
||||
// Test paths and output - used in generator tests.
|
||||
const (
|
||||
TestOutputPath = "/tmp/output"
|
||||
)
|
||||
|
||||
// Test HTML content - used in html tests.
|
||||
const (
|
||||
TestHTMLNewContent = "New content"
|
||||
TestHTMLClosingTag = "\n</html>"
|
||||
TestMsgFailedToReadOutput = "Failed to read output file: %v"
|
||||
)
|
||||
|
||||
// Test detector messages - used in detector tests.
|
||||
const (
|
||||
TestMsgFailedToCreateAction = "Failed to create action.yml: %v"
|
||||
TestPermRead = "read"
|
||||
TestPermWrite = "write"
|
||||
TestPermContents = "contents"
|
||||
)
|
||||
|
||||
// File permissions (additional).
|
||||
const (
|
||||
// FilePermDir is the directory permission.
|
||||
|
||||
212
appconstants/constants_test.go
Normal file
212
appconstants/constants_test.go
Normal file
@@ -0,0 +1,212 @@
|
||||
package appconstants
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
const testModifiedValue = "modified"
|
||||
|
||||
// TestGetSupportedThemes tests the GetSupportedThemes function.
|
||||
func TestGetSupportedThemes(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
themes := GetSupportedThemes()
|
||||
|
||||
// Check that we get a non-empty slice
|
||||
if len(themes) == 0 {
|
||||
t.Error("GetSupportedThemes() returned empty slice")
|
||||
}
|
||||
|
||||
// Check that known themes are included
|
||||
expectedThemes := []string{ThemeDefault, ThemeGitHub, ThemeMinimal, ThemeProfessional}
|
||||
for _, expected := range expectedThemes {
|
||||
found := false
|
||||
for _, theme := range themes {
|
||||
if theme == expected {
|
||||
found = true
|
||||
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("GetSupportedThemes() missing expected theme: %s", expected)
|
||||
}
|
||||
}
|
||||
|
||||
// Verify it returns a copy (modifying returned slice shouldn't affect original)
|
||||
themes1 := GetSupportedThemes()
|
||||
themes2 := GetSupportedThemes()
|
||||
if len(themes1) != len(themes2) {
|
||||
t.Error("GetSupportedThemes() not returning consistent results")
|
||||
}
|
||||
|
||||
// Modify the returned slice
|
||||
if len(themes1) > 0 {
|
||||
themes1[0] = testModifiedValue
|
||||
// Get a fresh copy
|
||||
themes3 := GetSupportedThemes()
|
||||
// Should not be modified
|
||||
if themes3[0] == testModifiedValue {
|
||||
t.Error("GetSupportedThemes() not returning a copy - original was modified")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetConfigSearchPaths tests the GetConfigSearchPaths function.
|
||||
func TestGetConfigSearchPaths(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
paths := GetConfigSearchPaths()
|
||||
|
||||
// Check that we get a non-empty slice
|
||||
if len(paths) == 0 {
|
||||
t.Error("GetConfigSearchPaths() returned empty slice")
|
||||
}
|
||||
|
||||
// Check that it contains path-like strings
|
||||
for _, path := range paths {
|
||||
if path == "" {
|
||||
t.Error("GetConfigSearchPaths() contains empty string")
|
||||
}
|
||||
|
||||
// Validate path doesn't contain traversal components
|
||||
if strings.Contains(path, "..") {
|
||||
t.Errorf("GetConfigSearchPaths() path %q contains unsafe .. component", path)
|
||||
}
|
||||
|
||||
// Validate path is already cleaned
|
||||
cleanPath := filepath.Clean(path)
|
||||
if path != cleanPath {
|
||||
t.Errorf("GetConfigSearchPaths() path %q is not cleaned (should be %q)", path, cleanPath)
|
||||
}
|
||||
}
|
||||
|
||||
// Verify it returns a copy (modifying returned slice shouldn't affect original)
|
||||
paths1 := GetConfigSearchPaths()
|
||||
paths2 := GetConfigSearchPaths()
|
||||
if len(paths1) != len(paths2) {
|
||||
t.Error("GetConfigSearchPaths() not returning consistent results")
|
||||
}
|
||||
|
||||
// Modify the returned slice
|
||||
if len(paths1) > 0 {
|
||||
paths1[0] = testModifiedValue
|
||||
// Get a fresh copy
|
||||
paths3 := GetConfigSearchPaths()
|
||||
// Should not be modified
|
||||
if paths3[0] == testModifiedValue {
|
||||
t.Error("GetConfigSearchPaths() not returning a copy - original was modified")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetDefaultIgnoredDirectories tests the GetDefaultIgnoredDirectories function.
|
||||
func TestGetDefaultIgnoredDirectories(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dirs := GetDefaultIgnoredDirectories()
|
||||
|
||||
// Check that we get a non-empty slice
|
||||
if len(dirs) == 0 {
|
||||
t.Error("GetDefaultIgnoredDirectories() returned empty slice")
|
||||
}
|
||||
|
||||
// Check that known ignored directories are included
|
||||
expectedDirs := []string{DirGit, DirNodeModules, DirVendor, DirDist}
|
||||
for _, expected := range expectedDirs {
|
||||
found := false
|
||||
for _, dir := range dirs {
|
||||
if dir == expected {
|
||||
found = true
|
||||
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("GetDefaultIgnoredDirectories() missing expected directory: %s", expected)
|
||||
}
|
||||
}
|
||||
|
||||
// Verify it returns a copy (modifying returned slice shouldn't affect original)
|
||||
dirs1 := GetDefaultIgnoredDirectories()
|
||||
dirs2 := GetDefaultIgnoredDirectories()
|
||||
if len(dirs1) != len(dirs2) {
|
||||
t.Error("GetDefaultIgnoredDirectories() not returning consistent results")
|
||||
}
|
||||
|
||||
// Modify the returned slice
|
||||
if len(dirs1) > 0 {
|
||||
dirs1[0] = testModifiedValue
|
||||
// Get a fresh copy
|
||||
dirs3 := GetDefaultIgnoredDirectories()
|
||||
// Should not be modified
|
||||
if dirs3[0] == testModifiedValue {
|
||||
t.Error("GetDefaultIgnoredDirectories() not returning a copy - original was modified")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestConfigurationSourceString tests the String method for ConfigurationSource.
|
||||
func TestConfigurationSourceString(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
source ConfigurationSource
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "defaults source",
|
||||
source: SourceDefaults,
|
||||
want: ConfigKeyDefaults,
|
||||
},
|
||||
{
|
||||
name: "global source",
|
||||
source: SourceGlobal,
|
||||
want: ScopeGlobal,
|
||||
},
|
||||
{
|
||||
name: "repo override source",
|
||||
source: SourceRepoOverride,
|
||||
want: "repo-override",
|
||||
},
|
||||
{
|
||||
name: "repo config source",
|
||||
source: SourceRepoConfig,
|
||||
want: "repo-config",
|
||||
},
|
||||
{
|
||||
name: "action config source",
|
||||
source: SourceActionConfig,
|
||||
want: "action-config",
|
||||
},
|
||||
{
|
||||
name: "environment source",
|
||||
source: SourceEnvironment,
|
||||
want: "environment",
|
||||
},
|
||||
{
|
||||
name: "CLI flags source",
|
||||
source: SourceCLIFlags,
|
||||
want: "cli-flags",
|
||||
},
|
||||
{
|
||||
name: "unknown source",
|
||||
source: ConfigurationSource(999),
|
||||
want: "unknown",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
got := tt.source.String()
|
||||
if got != tt.want {
|
||||
t.Errorf("ConfigurationSource.String() = %q, want %q", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
package appconstants
|
||||
|
||||
// This file contains constants used exclusively for testing.
|
||||
// These are separated from production constants to:
|
||||
// - Reduce API surface pollution in the main constants file
|
||||
// - Make it clear which constants are test-only
|
||||
// - Improve code organization and maintainability
|
||||
//
|
||||
// Note: These constants must remain exported so they can be used by
|
||||
// test files in other packages (e.g., internal/*_test.go, main_test.go).
|
||||
|
||||
// Test assertion message format templates.
|
||||
const (
|
||||
// TestMsgExitCode is the format for exit code mismatch assertions.
|
||||
TestMsgExitCode = "expected exit code %d, got %d"
|
||||
|
||||
// TestMsgStdout is the format for standard output logging.
|
||||
TestMsgStdout = "stdout: %s"
|
||||
|
||||
// TestMsgStderr is the format for standard error logging.
|
||||
TestMsgStderr = "stderr: %s"
|
||||
)
|
||||
|
||||
// Test fixture path constants.
|
||||
const (
|
||||
// JavaScript action fixtures.
|
||||
TestFixtureJavaScriptSimple = "actions/javascript/simple.yml"
|
||||
|
||||
// Composite action fixtures.
|
||||
TestFixtureCompositeBasic = "actions/composite/basic.yml"
|
||||
TestFixtureCompositeWithDeps = "actions/composite/with-dependencies.yml"
|
||||
|
||||
// Docker action fixtures.
|
||||
TestFixtureDockerBasic = "actions/docker/basic.yml"
|
||||
|
||||
// Invalid action fixtures.
|
||||
TestFixtureInvalidMissingDescription = "actions/invalid/missing-description.yml"
|
||||
TestFixtureInvalidInvalidUsing = "actions/invalid/invalid-using.yml"
|
||||
|
||||
// Minimal/other fixtures.
|
||||
TestFixtureMinimalAction = "minimal-action.yml"
|
||||
TestFixtureProfessionalConfig = "professional-config.yml"
|
||||
TestFixtureTestCompositeAction = "test-composite-action.yml"
|
||||
TestFixtureMyNewAction = "my-new-action.yml"
|
||||
)
|
||||
|
||||
// Test file path constants.
|
||||
const (
|
||||
TestPathActionYML = "action.yml"
|
||||
TestPathActionYAML = "action.yaml"
|
||||
TestPathConfigYML = "config.yml"
|
||||
TestPathCustomConfigYML = "custom-config.yml"
|
||||
TestPathNonexistentYML = "nonexistent.yml"
|
||||
)
|
||||
|
||||
// Test directory path constants.
|
||||
const (
|
||||
TestDirSubdir = "subdir"
|
||||
TestDirActions = "actions"
|
||||
TestDirActionsDeploy = "actions/deploy"
|
||||
TestDirActionsTest = "actions/test"
|
||||
TestDirActionsComposite = "actions/composite"
|
||||
TestDirActionsDocker = "actions/docker"
|
||||
TestDirNested = "nested"
|
||||
TestDirNestedDeep = "nested/deep"
|
||||
|
||||
// Config directories.
|
||||
TestDirConfigGhActionReadme = ".config/gh-action-readme"
|
||||
TestDirDotConfig = ".config"
|
||||
TestDirDotGitHub = ".github"
|
||||
TestDirCacheGhActionReadme = ".cache/gh-action-readme"
|
||||
)
|
||||
5
go.mod
5
go.mod
@@ -2,14 +2,15 @@ module github.com/ivuorinen/gh-action-readme
|
||||
|
||||
go 1.24.0
|
||||
|
||||
toolchain go1.25.5
|
||||
toolchain go1.25.6
|
||||
|
||||
require (
|
||||
github.com/adrg/xdg v0.5.3
|
||||
github.com/fatih/color v1.18.0
|
||||
github.com/goccy/go-yaml v1.19.1
|
||||
github.com/goccy/go-yaml v1.19.2
|
||||
github.com/gofri/go-github-ratelimit v1.1.1
|
||||
github.com/google/go-github/v74 v74.0.0
|
||||
github.com/leanovate/gopter v0.2.11
|
||||
github.com/schollz/progressbar/v3 v3.19.0
|
||||
github.com/spf13/cobra v1.10.2
|
||||
github.com/spf13/viper v1.21.0
|
||||
|
||||
603
go.sum
603
go.sum
@@ -1,22 +1,139 @@
|
||||
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
|
||||
cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
|
||||
cloud.google.com/go v0.38.0/go.mod h1:990N+gfupTy94rShfmMCWGDn0LpTmnzTp2qbd1dvSRU=
|
||||
cloud.google.com/go v0.44.1/go.mod h1:iSa0KzasP4Uvy3f1mN/7PiObzGgflwredwwASm/v6AU=
|
||||
cloud.google.com/go v0.44.2/go.mod h1:60680Gw3Yr4ikxnPRS/oxxkBccT6SA1yMk63TGekxKY=
|
||||
cloud.google.com/go v0.45.1/go.mod h1:RpBamKRgapWJb87xiFSdk4g1CME7QZg3uwTez+TSTjc=
|
||||
cloud.google.com/go v0.46.3/go.mod h1:a6bKKbmY7er1mI7TEI4lsAkts/mkhTSZK8w33B4RAg0=
|
||||
cloud.google.com/go v0.50.0/go.mod h1:r9sluTvynVuxRIOHXQEHMFffphuXHOMZMycpNR5e6To=
|
||||
cloud.google.com/go v0.52.0/go.mod h1:pXajvRH/6o3+F9jDHZWQ5PbGhn+o8w9qiu/CffaVdO4=
|
||||
cloud.google.com/go v0.53.0/go.mod h1:fp/UouUEsRkN6ryDKNW/Upv/JBKnv6WDthjR6+vze6M=
|
||||
cloud.google.com/go v0.54.0/go.mod h1:1rq2OEkV3YMf6n/9ZvGWI3GWw0VoqH/1x2nd8Is/bPc=
|
||||
cloud.google.com/go v0.56.0/go.mod h1:jr7tqZxxKOVYizybht9+26Z/gUq7tiRzu+ACVAMbKVk=
|
||||
cloud.google.com/go v0.57.0/go.mod h1:oXiQ6Rzq3RAkkY7N6t3TcE6jE+CIBBbA36lwQ1JyzZs=
|
||||
cloud.google.com/go v0.62.0/go.mod h1:jmCYTdRCQuc1PHIIJ/maLInMho30T/Y0M4hTdTShOYc=
|
||||
cloud.google.com/go v0.65.0/go.mod h1:O5N8zS7uWy9vkA9vayVHs65eM1ubvY4h553ofrNHObY=
|
||||
cloud.google.com/go v0.72.0/go.mod h1:M+5Vjvlc2wnp6tjzE102Dw08nGShTscUx2nZMufOKPI=
|
||||
cloud.google.com/go v0.74.0/go.mod h1:VV1xSbzvo+9QJOxLDaJfTjx5e+MePCpCWwvftOeQmWk=
|
||||
cloud.google.com/go v0.78.0/go.mod h1:QjdrLG0uq+YwhjoVOLsS1t7TW8fs36kLs4XO5R5ECHg=
|
||||
cloud.google.com/go v0.79.0/go.mod h1:3bzgcEeQlzbuEAYu4mrWhKqWjmpprinYgKJLgKHnbb8=
|
||||
cloud.google.com/go v0.81.0/go.mod h1:mk/AM35KwGk/Nm2YSeZbxXdrNK3KZOYHmLkOqC2V6E0=
|
||||
cloud.google.com/go/bigquery v1.0.1/go.mod h1:i/xbL2UlR5RvWAURpBYZTtm/cXjCha9lbfbpx4poX+o=
|
||||
cloud.google.com/go/bigquery v1.3.0/go.mod h1:PjpwJnslEMmckchkHFfq+HTD2DmtT67aNFKH1/VBDHE=
|
||||
cloud.google.com/go/bigquery v1.4.0/go.mod h1:S8dzgnTigyfTmLBfrtrhyYhwRxG72rYxvftPBK2Dvzc=
|
||||
cloud.google.com/go/bigquery v1.5.0/go.mod h1:snEHRnqQbz117VIFhE8bmtwIDY80NLUZUMb4Nv6dBIg=
|
||||
cloud.google.com/go/bigquery v1.7.0/go.mod h1://okPTzCYNXSlb24MZs83e2Do+h+VXtc4gLoIoXIAPc=
|
||||
cloud.google.com/go/bigquery v1.8.0/go.mod h1:J5hqkt3O0uAFnINi6JXValWIb1v0goeZM77hZzJN/fQ=
|
||||
cloud.google.com/go/datastore v1.0.0/go.mod h1:LXYbyblFSglQ5pkeyhO+Qmw7ukd3C+pD7TKLgZqpHYE=
|
||||
cloud.google.com/go/datastore v1.1.0/go.mod h1:umbIZjpQpHh4hmRpGhH4tLFup+FVzqBi1b3c64qFpCk=
|
||||
cloud.google.com/go/firestore v1.1.0/go.mod h1:ulACoGHTpvq5r8rxGJ4ddJZBZqakUQqClKRT5SZwBmk=
|
||||
cloud.google.com/go/pubsub v1.0.1/go.mod h1:R0Gpsv3s54REJCy4fxDixWD93lHJMoZTyQ2kNxGRt3I=
|
||||
cloud.google.com/go/pubsub v1.1.0/go.mod h1:EwwdRX2sKPjnvnqCa270oGRyludottCI76h+R3AArQw=
|
||||
cloud.google.com/go/pubsub v1.2.0/go.mod h1:jhfEVHT8odbXTkndysNHCcx0awwzvfOlguIAii9o8iA=
|
||||
cloud.google.com/go/pubsub v1.3.1/go.mod h1:i+ucay31+CNRpDW4Lu78I4xXG+O1r/MAHgjpRVR+TSU=
|
||||
cloud.google.com/go/storage v1.0.0/go.mod h1:IhtSnM/ZTZV8YYJWCY8RULGVqBDmpoyjwiyrjsg+URw=
|
||||
cloud.google.com/go/storage v1.5.0/go.mod h1:tpKbwo567HUNpVclU5sGELwQWBDZ8gh0ZeosJ0Rtdos=
|
||||
cloud.google.com/go/storage v1.6.0/go.mod h1:N7U0C8pVQ/+NIKOBQyamJIeKQKkZ+mxpohlUTyfDhBk=
|
||||
cloud.google.com/go/storage v1.8.0/go.mod h1:Wv1Oy7z6Yz3DshWRJFhqM/UCfaWIRTdp0RXyy7KQOVs=
|
||||
cloud.google.com/go/storage v1.10.0/go.mod h1:FLPqc6j+Ki4BU591ie1oL6qBQGu2Bl/tZ9ullr3+Kg0=
|
||||
dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU=
|
||||
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
|
||||
github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo=
|
||||
github.com/adrg/xdg v0.5.3 h1:xRnxJXne7+oWDatRhR1JLnvuccuIeCoBu2rtuLqQB78=
|
||||
github.com/adrg/xdg v0.5.3/go.mod h1:nlTsY+NNiCBGCK2tpm09vRqfVzrc2fLmXGpBLF0zlTQ=
|
||||
github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY=
|
||||
github.com/armon/circbuf v0.0.0-20150827004946-bbbad097214e/go.mod h1:3U/XgcO3hCbHZ8TKRvWD2dDTCfh9M9ya+I9JpbB7O8o=
|
||||
github.com/armon/go-metrics v0.0.0-20180917152333-f0300d1749da/go.mod h1:Q73ZrmVTwzkszR9V5SSuryQ31EELlFMUz1kKyl939pY=
|
||||
github.com/armon/go-radix v0.0.0-20180808171621-7fddfc383310/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8=
|
||||
github.com/bgentry/speakeasy v0.1.0/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kBD4zp0CCIs=
|
||||
github.com/bketelsen/crypt v0.0.4/go.mod h1:aI6NrJ0pMGgvZKL1iVgXLnfIFJtfV+bKCoqOes/6LfM=
|
||||
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
|
||||
github.com/chengxilo/virtualterm v1.0.4 h1:Z6IpERbRVlfB8WkOmtbHiDbBANU7cimRIof7mk9/PwM=
|
||||
github.com/chengxilo/virtualterm v1.0.4/go.mod h1:DyxxBZz/x1iqJjFxTFcr6/x+jSpqN0iwWCOK1q10rlY=
|
||||
github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
|
||||
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=
|
||||
github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
|
||||
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
|
||||
github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
|
||||
github.com/cncf/udpa/go v0.0.0-20200629203442-efcf912fb354/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk=
|
||||
github.com/cncf/udpa/go v0.0.0-20201120205902-5459f2c99403/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk=
|
||||
github.com/coreos/go-semver v0.3.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk=
|
||||
github.com/coreos/go-systemd/v22 v22.3.2/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.0/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
|
||||
github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
|
||||
github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
|
||||
github.com/envoyproxy/go-control-plane v0.9.7/go.mod h1:cwu0lG7PUMfa9snN8LXBig5ynNVH9qI8YYLbd1fK2po=
|
||||
github.com/envoyproxy/go-control-plane v0.9.9-0.20201210154907-fd9021fe5dad/go.mod h1:cXg6YxExXjJnVBQHBLXeUAgxn2UodCpnH306RInaBQk=
|
||||
github.com/envoyproxy/go-control-plane v0.9.9-0.20210217033140-668b12f5399d/go.mod h1:cXg6YxExXjJnVBQHBLXeUAgxn2UodCpnH306RInaBQk=
|
||||
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
|
||||
github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4=
|
||||
github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
|
||||
github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=
|
||||
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
|
||||
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
|
||||
github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
|
||||
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
|
||||
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
|
||||
github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04=
|
||||
github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU=
|
||||
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
|
||||
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
|
||||
github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
|
||||
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
|
||||
github.com/goccy/go-yaml v1.19.1 h1:3rG3+v8pkhRqoQ/88NYNMHYVGYztCOCIZ7UQhu7H+NE=
|
||||
github.com/goccy/go-yaml v1.19.1/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
||||
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
|
||||
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
||||
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
|
||||
github.com/gofri/go-github-ratelimit v1.1.1 h1:5TCOtFf45M2PjSYU17txqbiYBEzjOuK1+OhivbW69W0=
|
||||
github.com/gofri/go-github-ratelimit v1.1.1/go.mod h1:wGZlBbzHmIVjwDR3pZgKY7RBTV6gsQWxLVkpfwhcMJM=
|
||||
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
|
||||
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
|
||||
github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
|
||||
github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
|
||||
github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
|
||||
github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
|
||||
github.com/golang/mock v1.2.0/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
|
||||
github.com/golang/mock v1.3.1/go.mod h1:sBzyDLLjw3U8JLTeZvSv8jJB+tU5PVekmnlKIyFUx0Y=
|
||||
github.com/golang/mock v1.4.0/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw=
|
||||
github.com/golang/mock v1.4.1/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw=
|
||||
github.com/golang/mock v1.4.3/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw=
|
||||
github.com/golang/mock v1.4.4/go.mod h1:l3mdAwkq5BuhzHwde/uurv3sEJeZMXNpwsxVWU71h+4=
|
||||
github.com/golang/mock v1.5.0/go.mod h1:CWnOUgYIOo4TcNZ0wHX3YZCqsaM1I1Jvs6v3mP3KVu8=
|
||||
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||
github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||
github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw=
|
||||
github.com/golang/protobuf v1.3.4/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw=
|
||||
github.com/golang/protobuf v1.3.5/go.mod h1:6O5/vntMXwX2lRkT1hjjk0nAC1IDOTvTlVgjlRvqsdk=
|
||||
github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
|
||||
github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
|
||||
github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs=
|
||||
github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w=
|
||||
github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=
|
||||
github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8=
|
||||
github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
|
||||
github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
|
||||
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
|
||||
github.com/golang/protobuf v1.5.1/go.mod h1:DopwsBzvsk0Fs44TXzsVbJyPhcCPeIwnvohx4u74HPM=
|
||||
github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
|
||||
github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
|
||||
github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
|
||||
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
|
||||
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
||||
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
||||
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.4.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.5.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
@@ -24,61 +141,543 @@ github.com/google/go-github/v74 v74.0.0 h1:yZcddTUn8DPbj11GxnMrNiAnXH14gNs559AsU
|
||||
github.com/google/go-github/v74 v74.0.0/go.mod h1:ubn/YdyftV80VPSI26nSJvaEsTOnsjrxG3o9kJhcyak=
|
||||
github.com/google/go-querystring v1.2.0 h1:yhqkPbu2/OH+V9BfpCVPZkNmUXhb2gBxJArfhIxNtP0=
|
||||
github.com/google/go-querystring v1.2.0/go.mod h1:8IFJqpSRITyJ8QhQ13bmbeMBDfmeEJZD5A0egEOmkqU=
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs=
|
||||
github.com/google/martian/v3 v3.0.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0=
|
||||
github.com/google/martian/v3 v3.1.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0=
|
||||
github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc=
|
||||
github.com/google/pprof v0.0.0-20190515194954-54271f7e092f/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc=
|
||||
github.com/google/pprof v0.0.0-20191218002539-d4f498aebedc/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
|
||||
github.com/google/pprof v0.0.0-20200212024743-f11f1df84d12/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
|
||||
github.com/google/pprof v0.0.0-20200229191704-1ebb73c60ed3/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
|
||||
github.com/google/pprof v0.0.0-20200430221834-fc25d7d30c6d/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
|
||||
github.com/google/pprof v0.0.0-20200708004538-1a94d8640e99/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
|
||||
github.com/google/pprof v0.0.0-20201023163331-3e6fc7fc9c4c/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
|
||||
github.com/google/pprof v0.0.0-20201203190320-1bf35d6f28c2/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
|
||||
github.com/google/pprof v0.0.0-20210122040257-d980be63207e/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
|
||||
github.com/google/pprof v0.0.0-20210226084205-cbba55b83ad5/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
|
||||
github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI=
|
||||
github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg=
|
||||
github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk=
|
||||
github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
|
||||
github.com/gopherjs/gopherjs v1.17.2/go.mod h1:pRRIvn/QzFLrKfvEz3qUuEhtE/zLCWfreZ6J5gM2i+k=
|
||||
github.com/grpc-ecosystem/grpc-gateway v1.16.0/go.mod h1:BDjrQk3hbvj6Nolgz8mAMFbcEtjT1g+wF4CSlocrBnw=
|
||||
github.com/hashicorp/consul/api v1.1.0/go.mod h1:VmuI/Lkw1nC05EYQWNKwWGbkg+FbDBtguAZLlVdkD9Q=
|
||||
github.com/hashicorp/consul/sdk v0.1.1/go.mod h1:VKf9jXwCTEY1QZP2MOLRhb5i/I/ssyNV1vwHyQBF0x8=
|
||||
github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
|
||||
github.com/hashicorp/go-cleanhttp v0.5.1/go.mod h1:JpRdi6/HCYpAwUzNwuwqhbovhLtngrth3wmdIIUrZ80=
|
||||
github.com/hashicorp/go-immutable-radix v1.0.0/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60=
|
||||
github.com/hashicorp/go-msgpack v0.5.3/go.mod h1:ahLV/dePpqEmjfWmKiqvPkv/twdG7iPBM1vqhUKIvfM=
|
||||
github.com/hashicorp/go-multierror v1.0.0/go.mod h1:dHtQlpGsu+cZNNAkkCN/P3hoUDHhCYQXV3UM06sGGrk=
|
||||
github.com/hashicorp/go-rootcerts v1.0.0/go.mod h1:K6zTfqpRlCUIjkwsN4Z+hiSfzSTQa6eBIzfwKfwNnHU=
|
||||
github.com/hashicorp/go-sockaddr v1.0.0/go.mod h1:7Xibr9yA9JjQq1JpNB2Vw7kxv8xerXegt+ozgdvDeDU=
|
||||
github.com/hashicorp/go-syslog v1.0.0/go.mod h1:qPfqrKkXGihmCqbJM2mZgkZGvKG1dFdvsLplgctolz4=
|
||||
github.com/hashicorp/go-uuid v1.0.0/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
|
||||
github.com/hashicorp/go-uuid v1.0.1/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
|
||||
github.com/hashicorp/go.net v0.0.1/go.mod h1:hjKkEWcCURg++eb33jQU7oqQcI9XDCnUzHA0oac0k90=
|
||||
github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
|
||||
github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
|
||||
github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
|
||||
github.com/hashicorp/logutils v1.0.0/go.mod h1:QIAnNjmIWmVIIkWDTG1z5v++HQmx9WQRO+LraFDTW64=
|
||||
github.com/hashicorp/mdns v1.0.0/go.mod h1:tL+uN++7HEJ6SQLQ2/p+z2pH24WQKWjBPkE0mNTz8vQ=
|
||||
github.com/hashicorp/memberlist v0.1.3/go.mod h1:ajVTdAv/9Im8oMAAj5G31PhhMCZJV2pPBoIllUwCN7I=
|
||||
github.com/hashicorp/serf v0.8.2/go.mod h1:6hOLApaqBFA1NXqRQAsxw9QxuDEvNxSQRwA/JwenrHc=
|
||||
github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
|
||||
github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
|
||||
github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8=
|
||||
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||
github.com/json-iterator/go v1.1.11/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
|
||||
github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU=
|
||||
github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk=
|
||||
github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
|
||||
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
|
||||
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
|
||||
github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg=
|
||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/leanovate/gopter v0.2.11 h1:vRjThO1EKPb/1NsDXuDrzldR28RLkBflWYcU9CvzWu4=
|
||||
github.com/leanovate/gopter v0.2.11/go.mod h1:aK3tzZP/C+p1m3SPRE4SYZFGP7jjkuSI4f7Xvpt0S9c=
|
||||
github.com/magiconair/properties v1.8.5/go.mod h1:y3VJvCyxH9uVvJTWEGAELF3aiYNyPKd5NZ3oSwXrF60=
|
||||
github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU=
|
||||
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
|
||||
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
||||
github.com/mattn/go-isatty v0.0.3/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
|
||||
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
|
||||
github.com/miekg/dns v1.0.14/go.mod h1:W1PPwlIAgtquWBMBEV9nkV9Cazfe8ScdGz/Lj7v3Nrg=
|
||||
github.com/mitchellh/cli v1.0.0/go.mod h1:hNIlj7HEI86fIcpObd7a0FcrxTWetlwJDGcceTlRvqc=
|
||||
github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db h1:62I3jR2EmQ4l5rM/4FEfDWcRD+abF5XlKShorW5LRoQ=
|
||||
github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db/go.mod h1:l0dey0ia/Uv7NcFFVbCLtqEBQbrT4OCwCSKTEv6enCw=
|
||||
github.com/mitchellh/go-homedir v1.0.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
|
||||
github.com/mitchellh/go-testing-interface v1.0.0/go.mod h1:kRemZodwjscx+RGhAo8eIhFbs2+BFgRtFPeD/KE+zxI=
|
||||
github.com/mitchellh/gox v0.4.0/go.mod h1:Sd9lOJ0+aimLBi73mGofS1ycjY8lL3uZM3JPS42BGNg=
|
||||
github.com/mitchellh/iochan v1.0.0/go.mod h1:JwYml1nuB7xOzsp52dPpHFffvOCDupsG0QubkSMEySY=
|
||||
github.com/mitchellh/mapstructure v0.0.0-20160808181253-ca63d7c062ee/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y=
|
||||
github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y=
|
||||
github.com/mitchellh/mapstructure v1.4.1/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
|
||||
github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
|
||||
github.com/neelance/astrewrite v0.0.0-20160511093645-99348263ae86/go.mod h1:kHJEU3ofeGjhHklVoIGuVj85JJwZ6kWPaJwCIxgnFmo=
|
||||
github.com/neelance/sourcemap v0.0.0-20200213170602-2833bce08e4c/go.mod h1:Qr6/a/Q4r9LP1IltGz7tA7iOK1WonHEYhu1HRBA7ZiM=
|
||||
github.com/pascaldekloe/goe v0.0.0-20180627143212-57f6aae5913c/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc=
|
||||
github.com/pelletier/go-toml v1.9.3/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pkg/sftp v1.10.1/go.mod h1:lYOWFsE0bwd1+KfKJaKeuokY15vzFx25BLbzYYoAxZI=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/posener/complete v1.1.1/go.mod h1:em0nMJCgc9GFtwrmVmEMR/ZL6WyhyjMBndrE9hABlRI=
|
||||
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
|
||||
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||
github.com/rogpeppe/fastuuid v1.2.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6LYCDYWNEvQ=
|
||||
github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
|
||||
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
|
||||
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
|
||||
github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
github.com/ryanuber/columnize v0.0.0-20160712163229-9b3edd62028f/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts=
|
||||
github.com/sagikazarmark/locafero v0.12.0 h1:/NQhBAkUb4+fH1jivKHWusDYFjMOOKU88eegjfxfHb4=
|
||||
github.com/sagikazarmark/locafero v0.12.0/go.mod h1:sZh36u/YSZ918v0Io+U9ogLYQJ9tLLBmM4eneO6WwsI=
|
||||
github.com/schollz/progressbar/v3 v3.19.0 h1:Ea18xuIRQXLAUidVDox3AbwfUhD0/1IvohyTutOIFoc=
|
||||
github.com/schollz/progressbar/v3 v3.19.0/go.mod h1:IsO3lpbaGuzh8zIMzgY3+J8l4C8GjO0Y9S69eFvNsec=
|
||||
github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg65j358z/aeFdxmN0P9QXhEzd20vsDc=
|
||||
github.com/shurcooL/go v0.0.0-20200502201357-93f07166e636/go.mod h1:TDJrrUr11Vxrven61rcy3hJMUqaf/CLWYhHNPmT14Lk=
|
||||
github.com/shurcooL/httpfs v0.0.0-20190707220628-8d4bc4ba7749/go.mod h1:ZY1cvUeJuFPAdZ/B6v7RHavJWZn2YPVFQ1OSXhCGOkg=
|
||||
github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc=
|
||||
github.com/shurcooL/vfsgen v0.0.0-20200824052919-0d455de96546/go.mod h1:TrYk7fJVaAttu97ZZKrO9UbRa8izdowaMIZcxYMbVaw=
|
||||
github.com/sirupsen/logrus v1.8.1/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0=
|
||||
github.com/smarty/assertions v1.15.0/go.mod h1:yABtdzeQs6l1brC900WlRNwj6ZR55d7B+E8C6HtKdec=
|
||||
github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc=
|
||||
github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA=
|
||||
github.com/smartystreets/goconvey v1.8.1/go.mod h1:+/u4qLyY6x1jReYOp7GOM2FSt8aP9CzCZL03bI28W60=
|
||||
github.com/spf13/afero v1.6.0/go.mod h1:Ai8FlHk4v/PARR026UzYexafAt9roJ7LcLMAmO6Z93I=
|
||||
github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
|
||||
github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg=
|
||||
github.com/spf13/cast v1.3.1/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE=
|
||||
github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
|
||||
github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
|
||||
github.com/spf13/cobra v1.2.1/go.mod h1:ExllRjgxM/piMAM+3tAZvg8fsklGAf3tPfi+i8t68Nk=
|
||||
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
|
||||
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
|
||||
github.com/spf13/jwalterweatherman v1.1.0/go.mod h1:aNWZUN0dPAAO/Ljvb5BEdw96iTZ0EXowPYD95IqWIGo=
|
||||
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
|
||||
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/spf13/viper v1.8.1/go.mod h1:o0Pch8wJ9BVSWGQMbra6iw0oQ5oktSIBaujf1rJH9Ns=
|
||||
github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU=
|
||||
github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
||||
github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
|
||||
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/subosito/gotenv v1.2.0/go.mod h1:N0PQaV/YGNqwC0u51sEeR/aUtSLEXKX9iv69rRypqCw=
|
||||
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
|
||||
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
|
||||
github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
go.etcd.io/etcd/api/v3 v3.5.0/go.mod h1:cbVKeC6lCfl7j/8jBhAK6aIYO9XOjdptoxU/nLQcPvs=
|
||||
go.etcd.io/etcd/client/pkg/v3 v3.5.0/go.mod h1:IJHfcCEKxYu1Os13ZdwCwIUTUVGYTSAM3YSwc9/Ac1g=
|
||||
go.etcd.io/etcd/client/v2 v2.305.0/go.mod h1:h9puh54ZTgAKtEbut2oe9P4L/oqKCVB6xsXlzd7alYQ=
|
||||
go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU=
|
||||
go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8=
|
||||
go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
|
||||
go.opencensus.io v0.22.3/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
|
||||
go.opencensus.io v0.22.4/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
|
||||
go.opencensus.io v0.22.5/go.mod h1:5pWMHQbX5EPX2/62yrJeAkowc+lfs/XD7Uxpq3pI6kk=
|
||||
go.opencensus.io v0.23.0/go.mod h1:XItmlyltB5F7CS4xOC1DcqMoFqwtC6OG2xF7mCv7P7E=
|
||||
go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
|
||||
go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU=
|
||||
go.uber.org/zap v1.17.0/go.mod h1:MXVU+bhUf/A7Xi2HNOnopQOrmycQ5Ih87HtOu4q5SSo=
|
||||
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
|
||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||
golang.org/x/crypto v0.0.0-20181029021203-45a5f77698d3/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||
golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||
golang.org/x/crypto v0.0.0-20190820162420-60c769a6c586/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
golang.org/x/crypto v0.0.0-20210711020723-a769d52b0f97/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
||||
golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
||||
golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8=
|
||||
golang.org/x/exp v0.0.0-20190829153037-c13cbed26979/go.mod h1:86+5VVa7VpoJ4kLfm080zCjGlMRFzhUhsZKEZO7MGek=
|
||||
golang.org/x/exp v0.0.0-20191030013958-a1ab85dbe136/go.mod h1:JXzH8nQsPlswgeRAPE3MuO9GYsAcnJvJ4vnMwN/5qkY=
|
||||
golang.org/x/exp v0.0.0-20191129062945-2f5052295587/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4=
|
||||
golang.org/x/exp v0.0.0-20191227195350-da58074b4299/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4=
|
||||
golang.org/x/exp v0.0.0-20200119233911-0405dc783f0a/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4=
|
||||
golang.org/x/exp v0.0.0-20200207192155-f17229e696bd/go.mod h1:J/WKrq2StrnmMY6+EHIKF9dgMWnmCNThgcyBT1FY9mM=
|
||||
golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6/go.mod h1:3jZMyOhIsHpP37uCMkUooju7aAi5cS1Q23tOzKc+0MU=
|
||||
golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js=
|
||||
golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
|
||||
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
|
||||
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
|
||||
golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
|
||||
golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
|
||||
golang.org/x/lint v0.0.0-20190409202823-959b441ac422/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
|
||||
golang.org/x/lint v0.0.0-20190909230951-414d861bb4ac/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
|
||||
golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
|
||||
golang.org/x/lint v0.0.0-20191125180803-fdd1cda4f05f/go.mod h1:5qLYkcX4OjUUV8bRuDixDT3tpyyb+LUpUlRWLxfhWrs=
|
||||
golang.org/x/lint v0.0.0-20200130185559-910be7a94367/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=
|
||||
golang.org/x/lint v0.0.0-20200302205851-738671d3881b/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=
|
||||
golang.org/x/lint v0.0.0-20201208152925-83fdc39ff7b5/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=
|
||||
golang.org/x/lint v0.0.0-20210508222113-6edffad5e616/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=
|
||||
golang.org/x/mobile v0.0.0-20190312151609-d3739f865fa6/go.mod h1:z+o9i4GpDbdi3rU15maQ/Ox0txvL9dWGYEHz965HBQE=
|
||||
golang.org/x/mobile v0.0.0-20190719004257-d2bd2a29d028/go.mod h1:E/iHnbuqvinMTCcRqshq8CkpyQDoeVncDDYHnLhea+o=
|
||||
golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc=
|
||||
golang.org/x/mod v0.1.0/go.mod h1:0QHyrYULN0/3qlju5TqG8bIK38QM8yzMo5ekMj3DlcY=
|
||||
golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
|
||||
golang.org/x/mod v0.1.1-0.20191107180719-034126e5016b/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
|
||||
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/mod v0.4.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/mod v0.4.1/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.9.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20181023162649-9b4f9f5ad519/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20181201002055-351d144fa1fc/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190501004415-9ce7a6920f09/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190503192946-f4e77d36d62c/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20190628185345-da137c7871d7/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20190724013045-ca1201d0de80/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20191209160850-c0dbc17a3553/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20200222125558-5a598a2470a0/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20200301022130-244492dfa37a/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20200324143707-d3edc9973b7e/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
|
||||
golang.org/x/net v0.0.0-20200501053045-e0ff5e5a1de5/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
|
||||
golang.org/x/net v0.0.0-20200506145744-7e3656a0809f/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
|
||||
golang.org/x/net v0.0.0-20200513185701-a91f0712d120/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
|
||||
golang.org/x/net v0.0.0-20200520182314-0ba52f642ac2/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
|
||||
golang.org/x/net v0.0.0-20200625001655-4c5254603344/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
|
||||
golang.org/x/net v0.0.0-20200707034311-ab3426394381/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
|
||||
golang.org/x/net v0.0.0-20200822124328-c89045814202/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
|
||||
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
||||
golang.org/x/net v0.0.0-20201031054903-ff519b6c9102/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
||||
golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
||||
golang.org/x/net v0.0.0-20201209123823-ac852fbbde11/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20210119194325-5f4716e94777/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20210316092652-d523dce5a7f4/go.mod h1:RBQZq4jEuRlivfhVLdyRGr576XBO4/greRjx4P4O3yc=
|
||||
golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM=
|
||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||
golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc=
|
||||
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
||||
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
|
||||
golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
|
||||
golang.org/x/oauth2 v0.0.0-20191202225959-858c2ad4c8b6/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
|
||||
golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
|
||||
golang.org/x/oauth2 v0.0.0-20200902213428-5d25da1a8d43/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
|
||||
golang.org/x/oauth2 v0.0.0-20201109201403-9fd604954f58/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
|
||||
golang.org/x/oauth2 v0.0.0-20201208152858-08078c50e5b5/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
|
||||
golang.org/x/oauth2 v0.0.0-20210218202405-ba52d332ba99/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
|
||||
golang.org/x/oauth2 v0.0.0-20210220000619-9bb904979d93/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
|
||||
golang.org/x/oauth2 v0.0.0-20210313182246-cd4f82c27b84/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
|
||||
golang.org/x/oauth2 v0.0.0-20210402161424-2e8d93401602/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
|
||||
golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw=
|
||||
golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
|
||||
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20200317015054-43a5402ce75a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20181026203630-95b1ffbd15a5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20190502145724-3ef323f4f1fd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20190606165138-5da285871e9c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20190624142023-c5567b49c5d0/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20190726091711-fc99dfbffb4e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20191001151750-bb3f8db39f24/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20191228213918-04cbcbbfeed8/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200113162924-86b910548bc1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200122134326-e047566fdf82/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200202164722-d101bd2416d5/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200212091648-12a6c2dcc1e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200302150141-5c8b2ff67527/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200331124033-c3d80250170d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200501052902-10377860bb8e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200511232937-7e40ca221e25/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200515095857-1151b9dac4a9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200523222454-059865788121/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200803210538-64077c9b5642/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200905004654-be1d3432aa8f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201201145000-ef89a241ccb3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210104204734-6f8348627aad/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210119212857-b64e53b001e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210220050731-9a76102bfb43/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210305230114-8fe3ee5dd75b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210315160823-c6e025ad8005/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210320140829-1e4c9ba3b0c4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210403161142-5e06dd20ab57/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
|
||||
golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||
golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U=
|
||||
golang.org/x/term v0.38.0 h1:PQ5pkm/rLO6HnxFR7N2lJHOZX6Kez5Y1gDSJla6jo7Q=
|
||||
golang.org/x/term v0.38.0/go.mod h1:bSEAKrOT1W+VSu9TSCMtoGEOUcKxOKgl3LE5QEF/xVg=
|
||||
golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||
golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
||||
golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=
|
||||
golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=
|
||||
golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||
golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||
golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
|
||||
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
|
||||
golang.org/x/tools v0.0.0-20190312151545-0bb0c0a6e846/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
|
||||
golang.org/x/tools v0.0.0-20190312170243-e65039ee4138/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
|
||||
golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
|
||||
golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
|
||||
golang.org/x/tools v0.0.0-20190506145303-2d16b83fe98c/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
|
||||
golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
|
||||
golang.org/x/tools v0.0.0-20190606124116-d0a3d012864b/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
|
||||
golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
|
||||
golang.org/x/tools v0.0.0-20190628153133-6cdbf07be9d0/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
|
||||
golang.org/x/tools v0.0.0-20190816200558-6889da9d5479/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.0.0-20190911174233-4f2ddba30aff/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.0.0-20191012152004-8de300cfc20a/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.0.0-20191112195655-aa38f8e97acc/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.0.0-20191113191852-77e3bb0ad9e7/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.0.0-20191115202509-3a792d9c32b2/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.0.0-20191125144606-a911d9008d1f/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.0.0-20191130070609-6e064ea0cf2d/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.0.0-20191216173652-a0e659d51361/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
|
||||
golang.org/x/tools v0.0.0-20191227053925-7b8e75db28f4/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
|
||||
golang.org/x/tools v0.0.0-20200117161641-43d50277825c/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
|
||||
golang.org/x/tools v0.0.0-20200122220014-bf1340f18c4a/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
|
||||
golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
|
||||
golang.org/x/tools v0.0.0-20200204074204-1cc6d1ef6c74/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
|
||||
golang.org/x/tools v0.0.0-20200207183749-b753a1ba74fa/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
|
||||
golang.org/x/tools v0.0.0-20200212150539-ea181f53ac56/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
|
||||
golang.org/x/tools v0.0.0-20200224181240-023911ca70b2/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
|
||||
golang.org/x/tools v0.0.0-20200227222343-706bc42d1f0d/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
|
||||
golang.org/x/tools v0.0.0-20200304193943-95d2e580d8eb/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw=
|
||||
golang.org/x/tools v0.0.0-20200312045724-11d5b4c81c7d/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw=
|
||||
golang.org/x/tools v0.0.0-20200331025713-a30bf2db82d4/go.mod h1:Sl4aGygMT6LrqrWclx+PTx3U+LnKx/seiNR+3G19Ar8=
|
||||
golang.org/x/tools v0.0.0-20200501065659-ab2804fb9c9d/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
|
||||
golang.org/x/tools v0.0.0-20200512131952-2bc93b1c0c88/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
|
||||
golang.org/x/tools v0.0.0-20200515010526-7d3b6ebf133d/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
|
||||
golang.org/x/tools v0.0.0-20200618134242-20370b0cb4b2/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
|
||||
golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
|
||||
golang.org/x/tools v0.0.0-20200729194436-6467de6f59a7/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=
|
||||
golang.org/x/tools v0.0.0-20200804011535-6c149bb5ef0d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=
|
||||
golang.org/x/tools v0.0.0-20200825202427-b303f430e36d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=
|
||||
golang.org/x/tools v0.0.0-20200904185747-39188db58858/go.mod h1:Cj7w3i3Rnn0Xh82ur9kSqwfTHTeVxaDqrfMjpcNT6bE=
|
||||
golang.org/x/tools v0.0.0-20201110124207-079ba7bd75cd/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
|
||||
golang.org/x/tools v0.0.0-20201201161351-ac6f37ff4c2a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
|
||||
golang.org/x/tools v0.0.0-20201208233053-a543418bbed2/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
|
||||
golang.org/x/tools v0.0.0-20210105154028-b0ab187a4818/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
|
||||
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
|
||||
golang.org/x/tools v0.1.0/go.mod h1:xkSsbof2nBLbhDlRMhhhyNLN/zl3eTqcnHD5viDpcZ0=
|
||||
golang.org/x/tools v0.1.2/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
|
||||
golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
|
||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||
golang.org/x/tools v0.7.0/go.mod h1:4pg6aUX35JBAogB10C9AtvVL+qowtN4pT3CGSQex14s=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
google.golang.org/api v0.4.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE=
|
||||
google.golang.org/api v0.7.0/go.mod h1:WtwebWUNSVBH/HAw79HIFXZNqEvBhG+Ra+ax0hx3E3M=
|
||||
google.golang.org/api v0.8.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg=
|
||||
google.golang.org/api v0.9.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg=
|
||||
google.golang.org/api v0.13.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI=
|
||||
google.golang.org/api v0.14.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI=
|
||||
google.golang.org/api v0.15.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI=
|
||||
google.golang.org/api v0.17.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
|
||||
google.golang.org/api v0.18.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
|
||||
google.golang.org/api v0.19.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
|
||||
google.golang.org/api v0.20.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
|
||||
google.golang.org/api v0.22.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
|
||||
google.golang.org/api v0.24.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0MncE=
|
||||
google.golang.org/api v0.28.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0MncE=
|
||||
google.golang.org/api v0.29.0/go.mod h1:Lcubydp8VUV7KeIHD9z2Bys/sm/vGKnG1UHuDBSrHWM=
|
||||
google.golang.org/api v0.30.0/go.mod h1:QGmEvQ87FHZNiUVJkT14jQNYJ4ZJjdRF23ZXz5138Fc=
|
||||
google.golang.org/api v0.35.0/go.mod h1:/XrVsuzM0rZmrsbjJutiuftIzeuTQcEeaYcSk/mQ1dg=
|
||||
google.golang.org/api v0.36.0/go.mod h1:+z5ficQTmoYpPn8LCUNVpK5I7hwkpjbcgqA7I34qYtE=
|
||||
google.golang.org/api v0.40.0/go.mod h1:fYKFpnQN0DsDSKRVRcQSDQNtqWPfM9i+zNPxepjRCQ8=
|
||||
google.golang.org/api v0.41.0/go.mod h1:RkxM5lITDfTzmyKFPt+wGrCJbVfniCr2ool8kTBzRTU=
|
||||
google.golang.org/api v0.43.0/go.mod h1:nQsDGjRXMo4lvh5hP0TKqF244gqhGcr/YSIykhUk/94=
|
||||
google.golang.org/api v0.44.0/go.mod h1:EBOGZqzyhtvMDoxwS97ctnh0zUmYY6CxqXsc1AvkYD8=
|
||||
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
|
||||
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
|
||||
google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
|
||||
google.golang.org/appengine v1.6.1/go.mod h1:i06prIuMbXzDqacNJfV5OdTW448YApPu5ww/cMBSeb0=
|
||||
google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
|
||||
google.golang.org/appengine v1.6.6/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
|
||||
google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
|
||||
google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
|
||||
google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
|
||||
google.golang.org/genproto v0.0.0-20190418145605-e7d98fc518a7/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
|
||||
google.golang.org/genproto v0.0.0-20190425155659-357c62f0e4bb/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
|
||||
google.golang.org/genproto v0.0.0-20190502173448-54afdca5d873/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
|
||||
google.golang.org/genproto v0.0.0-20190801165951-fa694d86fc64/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
|
||||
google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
|
||||
google.golang.org/genproto v0.0.0-20190911173649-1774047e7e51/go.mod h1:IbNlFCBrqXvoKpeg0TB2l7cyZUmoaFKYIwrEpbDKLA8=
|
||||
google.golang.org/genproto v0.0.0-20191108220845-16a3f7862a1a/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
|
||||
google.golang.org/genproto v0.0.0-20191115194625-c23dd37a84c9/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
|
||||
google.golang.org/genproto v0.0.0-20191216164720-4f79533eabd1/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
|
||||
google.golang.org/genproto v0.0.0-20191230161307-f3c370f40bfb/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
|
||||
google.golang.org/genproto v0.0.0-20200115191322-ca5a22157cba/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
|
||||
google.golang.org/genproto v0.0.0-20200122232147-0452cf42e150/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
|
||||
google.golang.org/genproto v0.0.0-20200204135345-fa8e72b47b90/go.mod h1:GmwEX6Z4W5gMy59cAlVYjN9JhxgbQH6Gn+gFDQe2lzA=
|
||||
google.golang.org/genproto v0.0.0-20200212174721-66ed5ce911ce/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
|
||||
google.golang.org/genproto v0.0.0-20200224152610-e50cd9704f63/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
|
||||
google.golang.org/genproto v0.0.0-20200228133532-8c2c7df3a383/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
|
||||
google.golang.org/genproto v0.0.0-20200305110556-506484158171/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
|
||||
google.golang.org/genproto v0.0.0-20200312145019-da6875a35672/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
|
||||
google.golang.org/genproto v0.0.0-20200331122359-1ee6d9798940/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
|
||||
google.golang.org/genproto v0.0.0-20200430143042-b979b6f78d84/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
|
||||
google.golang.org/genproto v0.0.0-20200511104702-f5ebc3bea380/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
|
||||
google.golang.org/genproto v0.0.0-20200513103714-09dca8ec2884/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
|
||||
google.golang.org/genproto v0.0.0-20200515170657-fc4c6c6a6587/go.mod h1:YsZOwe1myG/8QRHRsmBRE1LrgQY60beZKjly0O1fX9U=
|
||||
google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo=
|
||||
google.golang.org/genproto v0.0.0-20200618031413-b414f8b61790/go.mod h1:jDfRM7FcilCzHH/e9qn6dsT145K34l5v+OpcnNgKAAA=
|
||||
google.golang.org/genproto v0.0.0-20200729003335-053ba62fc06f/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
|
||||
google.golang.org/genproto v0.0.0-20200804131852-c06518451d9c/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
|
||||
google.golang.org/genproto v0.0.0-20200825200019-8632dd797987/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
|
||||
google.golang.org/genproto v0.0.0-20200904004341-0bd0a958aa1d/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
|
||||
google.golang.org/genproto v0.0.0-20201109203340-2640f1f9cdfb/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
|
||||
google.golang.org/genproto v0.0.0-20201201144952-b05cb90ed32e/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
|
||||
google.golang.org/genproto v0.0.0-20201210142538-e3217bee35cc/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
|
||||
google.golang.org/genproto v0.0.0-20201214200347-8c77b98c765d/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
|
||||
google.golang.org/genproto v0.0.0-20210222152913-aa3ee6e6a81c/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
|
||||
google.golang.org/genproto v0.0.0-20210303154014-9728d6b83eeb/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
|
||||
google.golang.org/genproto v0.0.0-20210310155132-4ce2db91004e/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
|
||||
google.golang.org/genproto v0.0.0-20210319143718-93e7006c17a6/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
|
||||
google.golang.org/genproto v0.0.0-20210402141018-6c239bbf2bb1/go.mod h1:9lPAdzaEmUacj36I+k7YKbEc5CXzPIeORRgDAUOu28A=
|
||||
google.golang.org/genproto v0.0.0-20210602131652-f16073e35f0c/go.mod h1:UODoCrxHCcBojKKwX1terBiRUaqAsFqJiF615XL43r0=
|
||||
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
|
||||
google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38=
|
||||
google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM=
|
||||
google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
|
||||
google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY=
|
||||
google.golang.org/grpc v1.26.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
|
||||
google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
|
||||
google.golang.org/grpc v1.27.1/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
|
||||
google.golang.org/grpc v1.28.0/go.mod h1:rpkK4SK4GF4Ach/+MFLZUBavHOvF2JJB5uozKKal+60=
|
||||
google.golang.org/grpc v1.29.1/go.mod h1:itym6AZVZYACWQqET3MqgPpjcuV5QH3BxFS3IjizoKk=
|
||||
google.golang.org/grpc v1.30.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak=
|
||||
google.golang.org/grpc v1.31.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak=
|
||||
google.golang.org/grpc v1.31.1/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak=
|
||||
google.golang.org/grpc v1.33.1/go.mod h1:fr5YgcSWrqhRRxogOsw7RzIpsmvOZ6IcH4kBYTpR3n0=
|
||||
google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc=
|
||||
google.golang.org/grpc v1.34.0/go.mod h1:WotjhfgOW/POjDeRt8vscBtXq+2VjORFy659qA51WJ8=
|
||||
google.golang.org/grpc v1.35.0/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAGRRjU=
|
||||
google.golang.org/grpc v1.36.0/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAGRRjU=
|
||||
google.golang.org/grpc v1.36.1/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAGRRjU=
|
||||
google.golang.org/grpc v1.38.0/go.mod h1:NREThFqKR1f3iQ6oBuvc5LadQuXVGo9rkm5ZGrQdJfM=
|
||||
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
|
||||
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
|
||||
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
|
||||
google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE=
|
||||
google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo=
|
||||
google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
|
||||
google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
|
||||
google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
|
||||
google.golang.org/protobuf v1.24.0/go.mod h1:r/3tXBNzIEhYS9I1OUVjXDlt8tc493IdKGjtUeSXeh4=
|
||||
google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c=
|
||||
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
|
||||
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
|
||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
|
||||
gopkg.in/ini.v1 v1.62.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
|
||||
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.2.3/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
|
||||
honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
|
||||
honnef.co/go/tools v0.0.0-20190418001031-e561f6794a2a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
|
||||
honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
|
||||
honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg=
|
||||
honnef.co/go/tools v0.0.1-2020.1.3/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k=
|
||||
honnef.co/go/tools v0.0.1-2020.1.4/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k=
|
||||
rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8=
|
||||
rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0=
|
||||
rsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA=
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -92,7 +92,7 @@ func TestContextualErrorError(t *testing.T) {
|
||||
Code: appconstants.ErrCodeValidation,
|
||||
Err: errors.New("validation failed"),
|
||||
Context: "validating action.yml",
|
||||
Details: map[string]string{"file": "action.yml"},
|
||||
Details: map[string]string{"file": appconstants.ActionFileNameYML},
|
||||
Suggestions: []string{
|
||||
"Check required fields",
|
||||
"Validate YAML syntax",
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/ivuorinen/gh-action-readme/appconstants"
|
||||
"github.com/ivuorinen/gh-action-readme/testutil"
|
||||
)
|
||||
|
||||
// GetSuggestions returns context-aware suggestions for the given error code.
|
||||
@@ -76,7 +77,7 @@ func getFileNotFoundSuggestions(context map[string]string) []string {
|
||||
}
|
||||
|
||||
// Suggest common file names if looking for action files
|
||||
if strings.Contains(path, "action") {
|
||||
if strings.Contains(path, testutil.ConfigFieldAction) {
|
||||
suggestions = append(suggestions,
|
||||
"Common action file names: action.yml, action.yaml",
|
||||
"Check if the file is in a subdirectory",
|
||||
|
||||
@@ -8,24 +8,6 @@ import (
|
||||
"github.com/ivuorinen/gh-action-readme/testutil"
|
||||
)
|
||||
|
||||
// Test helper factories for creating context maps
|
||||
|
||||
func ctxPath(path string) map[string]string {
|
||||
return map[string]string{"path": path}
|
||||
}
|
||||
|
||||
func ctxError(err string) map[string]string {
|
||||
return map[string]string{"error": err}
|
||||
}
|
||||
|
||||
func ctxStatusCode(code string) map[string]string {
|
||||
return map[string]string{"status_code": code}
|
||||
}
|
||||
|
||||
func ctxEmpty() map[string]string {
|
||||
return map[string]string{}
|
||||
}
|
||||
|
||||
func TestGetSuggestions(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -38,7 +20,7 @@ func TestGetSuggestions(t *testing.T) {
|
||||
{
|
||||
name: "file not found with path",
|
||||
code: appconstants.ErrCodeFileNotFound,
|
||||
context: ctxPath("/path/to/action.yml"),
|
||||
context: testutil.ContextWithPath("/path/to/action.yml"),
|
||||
contains: []string{
|
||||
"Check if the file exists: /path/to/action.yml",
|
||||
"Verify the file path is correct",
|
||||
@@ -48,7 +30,7 @@ func TestGetSuggestions(t *testing.T) {
|
||||
{
|
||||
name: "file not found action file",
|
||||
code: appconstants.ErrCodeFileNotFound,
|
||||
context: ctxPath("/project/action.yml"),
|
||||
context: testutil.ContextWithPath("/project/action.yml"),
|
||||
contains: []string{
|
||||
"Common action file names: action.yml, action.yaml",
|
||||
"Check if the file is in a subdirectory",
|
||||
@@ -57,18 +39,16 @@ func TestGetSuggestions(t *testing.T) {
|
||||
{
|
||||
name: "permission denied",
|
||||
code: appconstants.ErrCodePermission,
|
||||
context: ctxPath("/restricted/file.txt"),
|
||||
context: testutil.ContextWithPath("/restricted/file.txt"),
|
||||
contains: []string{
|
||||
"Check file permissions: ls -la /restricted/file.txt",
|
||||
"chmod 644 /restricted/file.txt",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "invalid YAML with line number",
|
||||
code: appconstants.ErrCodeInvalidYAML,
|
||||
context: map[string]string{
|
||||
"line": "25",
|
||||
},
|
||||
name: "invalid YAML with line number",
|
||||
code: appconstants.ErrCodeInvalidYAML,
|
||||
context: testutil.ContextWithLine("25"),
|
||||
contains: []string{
|
||||
"Error near line 25",
|
||||
"Check YAML indentation",
|
||||
@@ -79,18 +59,16 @@ func TestGetSuggestions(t *testing.T) {
|
||||
{
|
||||
name: "invalid YAML with tab error",
|
||||
code: appconstants.ErrCodeInvalidYAML,
|
||||
context: ctxError("found character that cannot start any token (tab)"),
|
||||
context: testutil.ContextWithError("found character that cannot start any token (tab)"),
|
||||
contains: []string{
|
||||
"YAML files must use spaces for indentation, not tabs",
|
||||
"Replace all tabs with spaces",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "invalid action with missing fields",
|
||||
code: appconstants.ErrCodeInvalidAction,
|
||||
context: map[string]string{
|
||||
"missing_fields": "name, description",
|
||||
},
|
||||
name: "invalid action with missing fields",
|
||||
code: appconstants.ErrCodeInvalidAction,
|
||||
context: testutil.ContextWithMissingFields("name, description"),
|
||||
contains: []string{
|
||||
"Missing required fields: name, description",
|
||||
"required fields: name, description",
|
||||
@@ -98,11 +76,9 @@ func TestGetSuggestions(t *testing.T) {
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "no action files",
|
||||
code: appconstants.ErrCodeNoActionFiles,
|
||||
context: map[string]string{
|
||||
"directory": "/project",
|
||||
},
|
||||
name: testutil.TestCaseNameNoActionFiles,
|
||||
code: appconstants.ErrCodeNoActionFiles,
|
||||
context: testutil.ContextWithDirectory("/project"),
|
||||
contains: []string{
|
||||
"Current directory: /project",
|
||||
"find /project -name 'action.y*ml'",
|
||||
@@ -113,7 +89,7 @@ func TestGetSuggestions(t *testing.T) {
|
||||
{
|
||||
name: "GitHub API 401 error",
|
||||
code: appconstants.ErrCodeGitHubAPI,
|
||||
context: ctxStatusCode("401"),
|
||||
context: testutil.ContextWithStatusCode("401"),
|
||||
contains: []string{
|
||||
"Authentication failed",
|
||||
"check your GitHub token",
|
||||
@@ -123,7 +99,7 @@ func TestGetSuggestions(t *testing.T) {
|
||||
{
|
||||
name: "GitHub API 403 error",
|
||||
code: appconstants.ErrCodeGitHubAPI,
|
||||
context: ctxStatusCode("403"),
|
||||
context: testutil.ContextWithStatusCode("403"),
|
||||
contains: []string{
|
||||
"Access forbidden",
|
||||
"check token permissions",
|
||||
@@ -133,7 +109,7 @@ func TestGetSuggestions(t *testing.T) {
|
||||
{
|
||||
name: "GitHub API 404 error",
|
||||
code: appconstants.ErrCodeGitHubAPI,
|
||||
context: ctxStatusCode("404"),
|
||||
context: testutil.ContextWithStatusCode("404"),
|
||||
contains: []string{
|
||||
"Repository or resource not found",
|
||||
"repository is private",
|
||||
@@ -142,7 +118,7 @@ func TestGetSuggestions(t *testing.T) {
|
||||
{
|
||||
name: "GitHub rate limit",
|
||||
code: appconstants.ErrCodeGitHubRateLimit,
|
||||
context: ctxEmpty(),
|
||||
context: testutil.EmptyContext(),
|
||||
contains: []string{
|
||||
"rate limit exceeded",
|
||||
"GITHUB_TOKEN",
|
||||
@@ -153,7 +129,7 @@ func TestGetSuggestions(t *testing.T) {
|
||||
{
|
||||
name: "GitHub auth",
|
||||
code: appconstants.ErrCodeGitHubAuth,
|
||||
context: ctxEmpty(),
|
||||
context: testutil.EmptyContext(),
|
||||
contains: []string{
|
||||
"export GITHUB_TOKEN",
|
||||
"gh auth login",
|
||||
@@ -162,11 +138,9 @@ func TestGetSuggestions(t *testing.T) {
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "configuration error with path",
|
||||
code: appconstants.ErrCodeConfiguration,
|
||||
context: map[string]string{
|
||||
"config_path": "~/.config/gh-action-readme/config.yaml",
|
||||
},
|
||||
name: "configuration error with path",
|
||||
code: appconstants.ErrCodeConfiguration,
|
||||
context: testutil.ContextWithConfigPath("~/.config/gh-action-readme/config.yaml"),
|
||||
contains: []string{
|
||||
"Config path: ~/.config/gh-action-readme/config.yaml",
|
||||
"ls -la ~/.config/gh-action-readme/config.yaml",
|
||||
@@ -174,11 +148,9 @@ func TestGetSuggestions(t *testing.T) {
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "validation error with invalid fields",
|
||||
code: appconstants.ErrCodeValidation,
|
||||
context: map[string]string{
|
||||
"invalid_fields": "runs.using, inputs.test",
|
||||
},
|
||||
name: "validation error with invalid fields",
|
||||
code: appconstants.ErrCodeValidation,
|
||||
context: testutil.ContextWithField("invalid_fields", "runs.using, inputs.test"),
|
||||
contains: []string{
|
||||
"Invalid fields: runs.using, inputs.test",
|
||||
"Check spelling and nesting",
|
||||
@@ -186,11 +158,9 @@ func TestGetSuggestions(t *testing.T) {
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "template error with theme",
|
||||
code: appconstants.ErrCodeTemplateRender,
|
||||
context: map[string]string{
|
||||
"theme": "custom",
|
||||
},
|
||||
name: "template error with theme",
|
||||
code: appconstants.ErrCodeTemplateRender,
|
||||
context: testutil.ContextWithField("theme", "custom"),
|
||||
contains: []string{
|
||||
"Current theme: custom",
|
||||
"Try using a different theme",
|
||||
@@ -198,11 +168,9 @@ func TestGetSuggestions(t *testing.T) {
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "file write error with output path",
|
||||
code: appconstants.ErrCodeFileWrite,
|
||||
context: map[string]string{
|
||||
"output_path": "/output/README.md",
|
||||
},
|
||||
name: "file write error with output path",
|
||||
code: appconstants.ErrCodeFileWrite,
|
||||
context: testutil.ContextWithField("output_path", "/output/README.md"),
|
||||
contains: []string{
|
||||
"Output directory: /output",
|
||||
"Check permissions: ls -la /output",
|
||||
@@ -210,11 +178,9 @@ func TestGetSuggestions(t *testing.T) {
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "dependency analysis error",
|
||||
code: appconstants.ErrCodeDependencyAnalysis,
|
||||
context: map[string]string{
|
||||
"action": "my-action",
|
||||
},
|
||||
name: "dependency analysis error",
|
||||
code: appconstants.ErrCodeDependencyAnalysis,
|
||||
context: testutil.ContextWithField("action", "my-action"),
|
||||
contains: []string{
|
||||
"Analyzing action: my-action",
|
||||
"GitHub token is set",
|
||||
@@ -222,11 +188,9 @@ func TestGetSuggestions(t *testing.T) {
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "cache access error",
|
||||
code: appconstants.ErrCodeCacheAccess,
|
||||
context: map[string]string{
|
||||
"cache_path": "~/.cache/gh-action-readme",
|
||||
},
|
||||
name: "cache access error",
|
||||
code: appconstants.ErrCodeCacheAccess,
|
||||
context: testutil.ContextWithField("cache_path", "~/.cache/gh-action-readme"),
|
||||
contains: []string{
|
||||
"Cache path: ~/.cache/gh-action-readme",
|
||||
"gh-action-readme cache clear",
|
||||
@@ -236,7 +200,7 @@ func TestGetSuggestions(t *testing.T) {
|
||||
{
|
||||
name: "unknown error code",
|
||||
code: "UNKNOWN_TEST_CODE",
|
||||
context: ctxEmpty(),
|
||||
context: testutil.EmptyContext(),
|
||||
contains: []string{
|
||||
"Check the error message",
|
||||
"--verbose flag",
|
||||
@@ -258,7 +222,7 @@ func TestGetSuggestions(t *testing.T) {
|
||||
func TestGetPermissionSuggestionsOSSpecific(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
context := map[string]string{"path": "/test/file"}
|
||||
context := testutil.ContextWithPath("/test/file")
|
||||
suggestions := getPermissionSuggestions(context)
|
||||
|
||||
switch runtime.GOOS {
|
||||
@@ -294,7 +258,7 @@ func TestGetSuggestionsEmptyContext(t *testing.T) {
|
||||
t.Run(string(code), func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
suggestions := GetSuggestions(code, map[string]string{})
|
||||
suggestions := GetSuggestions(code, testutil.EmptyContext())
|
||||
if len(suggestions) == 0 {
|
||||
t.Errorf("GetSuggestions(%s, {}) returned empty slice", code)
|
||||
}
|
||||
@@ -305,9 +269,7 @@ func TestGetSuggestionsEmptyContext(t *testing.T) {
|
||||
func TestGetFileNotFoundSuggestionsActionFile(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
context := map[string]string{
|
||||
"path": "/project/action.yml",
|
||||
}
|
||||
context := testutil.ContextWithPath("/project/action.yml")
|
||||
|
||||
suggestions := getFileNotFoundSuggestions(context)
|
||||
testutil.AssertSliceContainsAll(t, suggestions, []string{"action.yml, action.yaml", "subdirectory"})
|
||||
@@ -316,9 +278,7 @@ func TestGetFileNotFoundSuggestionsActionFile(t *testing.T) {
|
||||
func TestGetInvalidYAMLSuggestionsTabError(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
context := map[string]string{
|
||||
"error": "found character that cannot start any token, tab character",
|
||||
}
|
||||
context := testutil.ContextWithError("found character that cannot start any token, tab character")
|
||||
|
||||
suggestions := getInvalidYAMLSuggestions(context)
|
||||
testutil.AssertSliceContainsAll(t, suggestions, []string{"tabs with spaces"})
|
||||
@@ -337,9 +297,205 @@ func TestGetGitHubAPISuggestionsStatusCodes(t *testing.T) {
|
||||
t.Run("status_"+code, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
context := map[string]string{"status_code": code}
|
||||
context := testutil.ContextWithStatusCode(code)
|
||||
suggestions := getGitHubAPISuggestions(context)
|
||||
testutil.AssertSliceContainsAll(t, suggestions, []string{expectedText})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetValidationSuggestions tests the getValidationSuggestions function.
|
||||
func TestGetValidationSuggestions(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
context map[string]string
|
||||
expectedContains []string
|
||||
}{
|
||||
{
|
||||
name: "basic validation suggestions",
|
||||
context: map[string]string{},
|
||||
expectedContains: []string{
|
||||
"Review validation errors",
|
||||
"Check required fields",
|
||||
"Use 'gh-action-readme schema' to see valid structure",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "with invalid_fields context",
|
||||
context: testutil.ContextWithField("invalid_fields", "runs.using, description"),
|
||||
expectedContains: []string{
|
||||
"Invalid fields: runs.using, description",
|
||||
"Check spelling and nesting",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "with validation_type required",
|
||||
context: testutil.ContextWithField("validation_type", "required"),
|
||||
expectedContains: []string{
|
||||
"Add missing required fields",
|
||||
"name, description, runs",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "with validation_type type",
|
||||
context: testutil.ContextWithField("validation_type", "type"),
|
||||
expectedContains: []string{
|
||||
"Ensure field values match expected types",
|
||||
"Strings should be quoted",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "with both invalid_fields and validation_type",
|
||||
context: testutil.MergeContexts(
|
||||
testutil.ContextWithField("invalid_fields", "name"),
|
||||
testutil.ContextWithField("validation_type", "required"),
|
||||
),
|
||||
expectedContains: []string{
|
||||
"Invalid fields: name",
|
||||
"Add missing required fields",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
suggestions := getValidationSuggestions(tt.context)
|
||||
testutil.AssertSliceContainsAll(t, suggestions, tt.expectedContains)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetConfigurationSuggestions tests the getConfigurationSuggestions function.
|
||||
func TestGetConfigurationSuggestions(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
context map[string]string
|
||||
expectedContains []string
|
||||
}{
|
||||
{
|
||||
name: "basic configuration suggestions",
|
||||
context: map[string]string{},
|
||||
expectedContains: []string{
|
||||
"Check configuration file syntax",
|
||||
"Ensure configuration file exists",
|
||||
"Use 'gh-action-readme config init'",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "with config_path context",
|
||||
context: testutil.ContextWithConfigPath("/path/to/config.yaml"),
|
||||
expectedContains: []string{
|
||||
"Config path: /path/to/config.yaml",
|
||||
"Check if file exists: ls -la /path/to/config.yaml",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "with permission error in context",
|
||||
context: testutil.ContextWithError("permission denied"),
|
||||
expectedContains: []string{
|
||||
"Check file permissions for config file",
|
||||
"Ensure parent directory is writable",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "with both config_path and permission error",
|
||||
context: testutil.MergeContexts(
|
||||
testutil.ContextWithConfigPath("/restricted/config.yaml"),
|
||||
testutil.ContextWithError("permission denied while reading"),
|
||||
),
|
||||
expectedContains: []string{
|
||||
"Config path: /restricted/config.yaml",
|
||||
"Check file permissions for config file",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: testutil.TestCaseNamePathTraversal,
|
||||
context: testutil.ContextWithConfigPath("../../../etc/passwd"),
|
||||
expectedContains: []string{
|
||||
"Check configuration file syntax",
|
||||
"Ensure configuration file exists",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
suggestions := getConfigurationSuggestions(tt.context)
|
||||
testutil.AssertSliceContainsAll(t, suggestions, tt.expectedContains)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetTemplateSuggestions tests the getTemplateSuggestions function.
|
||||
func TestGetTemplateSuggestions(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
context map[string]string
|
||||
expectedContains []string
|
||||
}{
|
||||
{
|
||||
name: "basic template suggestions",
|
||||
context: map[string]string{},
|
||||
expectedContains: []string{
|
||||
"Check template syntax",
|
||||
"Ensure all template variables are defined",
|
||||
"Verify custom template path is correct",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "with template_path context",
|
||||
context: testutil.ContextWithField("template_path", "/path/to/custom-template.tmpl"),
|
||||
expectedContains: []string{
|
||||
"Template path: /path/to/custom-template.tmpl",
|
||||
"Ensure template file exists and is readable",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "with theme context",
|
||||
context: testutil.ContextWithField("theme", "custom-theme"),
|
||||
expectedContains: []string{
|
||||
"Current theme: custom-theme",
|
||||
"Try using a different theme: --theme github",
|
||||
"Available themes: default, github, gitlab, minimal, professional",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "with both template_path and theme",
|
||||
context: testutil.MergeContexts(
|
||||
testutil.ContextWithField("template_path", "/custom/template.tmpl"),
|
||||
testutil.ContextWithField("theme", "github"),
|
||||
),
|
||||
expectedContains: []string{
|
||||
"Template path: /custom/template.tmpl",
|
||||
"Current theme: github",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: testutil.TestCaseNamePathTraversal,
|
||||
context: testutil.ContextWithField("template_path", "../../../../../../etc/passwd"),
|
||||
expectedContains: []string{
|
||||
"Check template syntax",
|
||||
"Ensure all template variables are defined",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
suggestions := getTemplateSuggestions(tt.context)
|
||||
testutil.AssertSliceContainsAll(t, suggestions, tt.expectedContains)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
147
internal/cache/cache_test.go
vendored
147
internal/cache/cache_test.go
vendored
@@ -69,7 +69,7 @@ func TestNewCache(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCache_SetAndGet(t *testing.T) {
|
||||
func TestCacheSetAndGet(t *testing.T) {
|
||||
tmpDir, cleanup := testutil.TempDir(t)
|
||||
defer cleanup()
|
||||
|
||||
@@ -84,9 +84,9 @@ func TestCache_SetAndGet(t *testing.T) {
|
||||
}{
|
||||
{
|
||||
name: "string value",
|
||||
key: "test-key",
|
||||
value: "test-value",
|
||||
expected: "test-value",
|
||||
key: testutil.CacheTestKey,
|
||||
value: testutil.CacheTestValue,
|
||||
expected: testutil.CacheTestValue,
|
||||
},
|
||||
{
|
||||
name: "struct value",
|
||||
@@ -121,7 +121,7 @@ func TestCache_SetAndGet(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCache_TTL(t *testing.T) {
|
||||
func TestCacheTTL(t *testing.T) {
|
||||
tmpDir, cleanup := testutil.TempDir(t)
|
||||
defer cleanup()
|
||||
|
||||
@@ -130,11 +130,11 @@ func TestCache_TTL(t *testing.T) {
|
||||
|
||||
// Set value with short TTL
|
||||
shortTTL := 100 * time.Millisecond
|
||||
err := cache.SetWithTTL("short-lived", "value", shortTTL)
|
||||
err := cache.SetWithTTL(testutil.CacheShortLivedKey, "value", shortTTL)
|
||||
testutil.AssertNoError(t, err)
|
||||
|
||||
// Should exist immediately
|
||||
value, exists := cache.Get("short-lived")
|
||||
value, exists := cache.Get(testutil.CacheShortLivedKey)
|
||||
if !exists {
|
||||
t.Fatal("expected value to exist immediately")
|
||||
}
|
||||
@@ -144,13 +144,13 @@ func TestCache_TTL(t *testing.T) {
|
||||
time.Sleep(shortTTL + 50*time.Millisecond)
|
||||
|
||||
// Should not exist after TTL
|
||||
_, exists = cache.Get("short-lived")
|
||||
_, exists = cache.Get(testutil.CacheShortLivedKey)
|
||||
if exists {
|
||||
t.Error("expected value to be expired")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCache_GetOrSet(t *testing.T) {
|
||||
func TestCacheGetOrSet(t *testing.T) {
|
||||
tmpDir, cleanup := testutil.TempDir(t)
|
||||
defer cleanup()
|
||||
|
||||
@@ -180,7 +180,7 @@ func TestCache_GetOrSet(t *testing.T) {
|
||||
testutil.AssertEqual(t, 1, callCount) // Getter not called again
|
||||
}
|
||||
|
||||
func TestCache_GetOrSetError(t *testing.T) {
|
||||
func TestCacheGetOrSetError(t *testing.T) {
|
||||
tmpDir, cleanup := testutil.TempDir(t)
|
||||
defer cleanup()
|
||||
|
||||
@@ -207,7 +207,7 @@ func TestCache_GetOrSetError(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCache_ConcurrentAccess(t *testing.T) {
|
||||
func TestCacheConcurrentAccess(t *testing.T) {
|
||||
tmpDir, cleanup := testutil.TempDir(t)
|
||||
defer cleanup()
|
||||
|
||||
@@ -222,42 +222,45 @@ func TestCache_ConcurrentAccess(t *testing.T) {
|
||||
|
||||
// Launch multiple goroutines doing concurrent operations
|
||||
for i := 0; i < numGoroutines; i++ {
|
||||
go func(goroutineID int) {
|
||||
defer wg.Done()
|
||||
|
||||
for j := 0; j < numOperations; j++ {
|
||||
key := fmt.Sprintf("key-%d-%d", goroutineID, j)
|
||||
value := fmt.Sprintf("value-%d-%d", goroutineID, j)
|
||||
|
||||
// Set value
|
||||
err := cache.Set(key, value)
|
||||
if err != nil {
|
||||
t.Errorf("error setting value: %v", err)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// Get value
|
||||
retrieved, exists := cache.Get(key)
|
||||
if !exists {
|
||||
t.Errorf("expected key %s to exist", key)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if retrieved != value {
|
||||
t.Errorf("expected %s, got %s", value, retrieved)
|
||||
|
||||
return
|
||||
}
|
||||
}
|
||||
}(i)
|
||||
go performConcurrentCacheOperations(t, cache, i, numOperations, &wg)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
func TestCache_Persistence(t *testing.T) {
|
||||
func performConcurrentCacheOperations(t *testing.T, cache *Cache, goroutineID, numOperations int, wg *sync.WaitGroup) {
|
||||
t.Helper()
|
||||
defer wg.Done()
|
||||
|
||||
for j := 0; j < numOperations; j++ {
|
||||
key := fmt.Sprintf("key-%d-%d", goroutineID, j)
|
||||
value := fmt.Sprintf("value-%d-%d", goroutineID, j)
|
||||
|
||||
// Set value
|
||||
err := cache.Set(key, value)
|
||||
if err != nil {
|
||||
t.Errorf("error setting value: %v", err)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// Get value
|
||||
retrieved, exists := cache.Get(key)
|
||||
if !exists {
|
||||
t.Errorf("expected key %s to exist", key)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if retrieved != value {
|
||||
t.Errorf("expected %s, got %s", value, retrieved)
|
||||
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCachePersistence(t *testing.T) {
|
||||
tmpDir, cleanup := testutil.TempDir(t)
|
||||
defer cleanup()
|
||||
|
||||
@@ -282,7 +285,7 @@ func TestCache_Persistence(t *testing.T) {
|
||||
testutil.AssertEqual(t, "persistent-value", value)
|
||||
}
|
||||
|
||||
func TestCache_Clear(t *testing.T) {
|
||||
func TestCacheClear(t *testing.T) {
|
||||
tmpDir, cleanup := testutil.TempDir(t)
|
||||
defer cleanup()
|
||||
|
||||
@@ -290,12 +293,12 @@ func TestCache_Clear(t *testing.T) {
|
||||
defer testutil.CleanupCache(t, cache)()
|
||||
|
||||
// Add some data
|
||||
_ = cache.Set("key1", "value1")
|
||||
_ = cache.Set("key2", "value2")
|
||||
_ = cache.Set(testutil.CacheTestKey1, testutil.CacheTestValue1)
|
||||
_ = cache.Set(testutil.CacheTestKey2, "value2")
|
||||
|
||||
// Verify data exists
|
||||
_, exists1 := cache.Get("key1")
|
||||
_, exists2 := cache.Get("key2")
|
||||
_, exists1 := cache.Get(testutil.CacheTestKey1)
|
||||
_, exists2 := cache.Get(testutil.CacheTestKey2)
|
||||
if !exists1 || !exists2 {
|
||||
t.Fatal("expected test data to exist before clear")
|
||||
}
|
||||
@@ -305,14 +308,14 @@ func TestCache_Clear(t *testing.T) {
|
||||
testutil.AssertNoError(t, err)
|
||||
|
||||
// Verify data is gone
|
||||
_, exists1 = cache.Get("key1")
|
||||
_, exists2 = cache.Get("key2")
|
||||
_, exists1 = cache.Get(testutil.CacheTestKey1)
|
||||
_, exists2 = cache.Get(testutil.CacheTestKey2)
|
||||
if exists1 || exists2 {
|
||||
t.Error("expected data to be cleared")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCache_Delete(t *testing.T) {
|
||||
func TestCacheDelete(t *testing.T) {
|
||||
tmpDir, cleanup := testutil.TempDir(t)
|
||||
defer cleanup()
|
||||
|
||||
@@ -320,22 +323,22 @@ func TestCache_Delete(t *testing.T) {
|
||||
defer testutil.CleanupCache(t, cache)()
|
||||
|
||||
// Add some data
|
||||
_ = cache.Set("key1", "value1")
|
||||
_ = cache.Set("key2", "value2")
|
||||
_ = cache.Set(testutil.CacheTestKey1, testutil.CacheTestValue1)
|
||||
_ = cache.Set(testutil.CacheTestKey2, "value2")
|
||||
_ = cache.Set("key3", "value3")
|
||||
|
||||
// Verify data exists
|
||||
_, exists := cache.Get("key1")
|
||||
_, exists := cache.Get(testutil.CacheTestKey1)
|
||||
if !exists {
|
||||
t.Fatal("expected key1 to exist before delete")
|
||||
}
|
||||
|
||||
// Delete specific key
|
||||
cache.Delete("key1")
|
||||
cache.Delete(testutil.CacheTestKey1)
|
||||
|
||||
// Verify deleted key is gone but others remain
|
||||
_, exists1 := cache.Get("key1")
|
||||
_, exists2 := cache.Get("key2")
|
||||
_, exists1 := cache.Get(testutil.CacheTestKey1)
|
||||
_, exists2 := cache.Get(testutil.CacheTestKey2)
|
||||
_, exists3 := cache.Get("key3")
|
||||
|
||||
if exists1 {
|
||||
@@ -349,7 +352,7 @@ func TestCache_Delete(t *testing.T) {
|
||||
cache.Delete("nonexistent")
|
||||
}
|
||||
|
||||
func TestCache_Stats(t *testing.T) {
|
||||
func TestCacheStats(t *testing.T) {
|
||||
tmpDir, cleanup := testutil.TempDir(t)
|
||||
defer cleanup()
|
||||
|
||||
@@ -360,8 +363,8 @@ func TestCache_Stats(t *testing.T) {
|
||||
_ = cache.Clear()
|
||||
|
||||
// Add some data
|
||||
_ = cache.Set("key1", "value1")
|
||||
_ = cache.Set("key2", "larger-value-with-more-content")
|
||||
_ = cache.Set(testutil.CacheTestKey1, testutil.CacheTestValue1)
|
||||
_ = cache.Set(testutil.CacheTestKey2, "larger-value-with-more-content")
|
||||
|
||||
stats := cache.Stats()
|
||||
|
||||
@@ -397,7 +400,7 @@ func TestCache_Stats(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCache_CleanupExpiredEntries(t *testing.T) {
|
||||
func TestCacheCleanupExpiredEntries(t *testing.T) {
|
||||
tmpDir, cleanup := testutil.TempDir(t)
|
||||
defer cleanup()
|
||||
|
||||
@@ -415,11 +418,11 @@ func TestCache_CleanupExpiredEntries(t *testing.T) {
|
||||
defer testutil.CleanupCache(t, cache)()
|
||||
|
||||
// Add entry that will expire
|
||||
err = cache.Set("expiring-key", "expiring-value")
|
||||
err = cache.Set(testutil.CacheExpiringKey, "expiring-value")
|
||||
testutil.AssertNoError(t, err)
|
||||
|
||||
// Verify it exists
|
||||
_, exists := cache.Get("expiring-key")
|
||||
_, exists := cache.Get(testutil.CacheExpiringKey)
|
||||
if !exists {
|
||||
t.Fatal("expected entry to exist initially")
|
||||
}
|
||||
@@ -428,13 +431,13 @@ func TestCache_CleanupExpiredEntries(t *testing.T) {
|
||||
time.Sleep(config.DefaultTTL + config.CleanupInterval + 20*time.Millisecond)
|
||||
|
||||
// Entry should be cleaned up
|
||||
_, exists = cache.Get("expiring-key")
|
||||
_, exists = cache.Get(testutil.CacheExpiringKey)
|
||||
if exists {
|
||||
t.Error("expected expired entry to be cleaned up")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCache_ErrorHandling(t *testing.T) {
|
||||
func TestCacheErrorHandling(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
setupFunc func(t *testing.T) *Cache
|
||||
@@ -472,7 +475,7 @@ func TestCache_ErrorHandling(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCache_AsyncSaveErrorHandling(t *testing.T) {
|
||||
func TestCacheAsyncSaveErrorHandling(t *testing.T) {
|
||||
tmpDir, cleanup := testutil.TempDir(t)
|
||||
defer cleanup()
|
||||
|
||||
@@ -481,7 +484,7 @@ func TestCache_AsyncSaveErrorHandling(t *testing.T) {
|
||||
|
||||
// This tests our new saveToDiskAsync error handling
|
||||
// Set a value to trigger async save
|
||||
err := cache.Set("test-key", "test-value")
|
||||
err := cache.Set(testutil.CacheTestKey, testutil.CacheTestValue)
|
||||
testutil.AssertNoError(t, err)
|
||||
|
||||
// Give some time for async save to complete
|
||||
@@ -490,14 +493,14 @@ func TestCache_AsyncSaveErrorHandling(t *testing.T) {
|
||||
// The async save should have completed without panicking
|
||||
// We can't easily test the error logging without capturing logs,
|
||||
// but we can verify the cache still works
|
||||
value, exists := cache.Get("test-key")
|
||||
value, exists := cache.Get(testutil.CacheTestKey)
|
||||
if !exists {
|
||||
t.Error("expected value to exist after async save")
|
||||
}
|
||||
testutil.AssertEqual(t, "test-value", value)
|
||||
testutil.AssertEqual(t, testutil.CacheTestValue, value)
|
||||
}
|
||||
|
||||
func TestCache_EstimateSize(t *testing.T) {
|
||||
func TestCacheEstimateSize(t *testing.T) {
|
||||
tmpDir, cleanup := testutil.TempDir(t)
|
||||
defer cleanup()
|
||||
|
||||
@@ -525,9 +528,9 @@ func TestCache_EstimateSize(t *testing.T) {
|
||||
{
|
||||
name: "struct",
|
||||
value: map[string]any{
|
||||
"key1": "value1",
|
||||
"key2": 42,
|
||||
"key3": []string{"a", "b", "c"},
|
||||
testutil.CacheTestKey1: testutil.CacheTestValue1,
|
||||
testutil.CacheTestKey2: 42,
|
||||
"key3": []string{"a", "b", "c"},
|
||||
},
|
||||
minSize: 30,
|
||||
maxSize: 200,
|
||||
|
||||
@@ -16,7 +16,7 @@ import (
|
||||
"github.com/ivuorinen/gh-action-readme/appconstants"
|
||||
"github.com/ivuorinen/gh-action-readme/internal/git"
|
||||
"github.com/ivuorinen/gh-action-readme/internal/validation"
|
||||
"github.com/ivuorinen/gh-action-readme/templates_embed"
|
||||
templatesembed "github.com/ivuorinen/gh-action-readme/templates_embed"
|
||||
)
|
||||
|
||||
// AppConfig represents the application configuration that can be used at multiple levels.
|
||||
@@ -25,9 +25,10 @@ type AppConfig struct {
|
||||
GitHubToken string `mapstructure:"github_token" yaml:"github_token,omitempty"` // Only in global config
|
||||
|
||||
// Repository Information (auto-detected, overridable)
|
||||
Organization string `mapstructure:"organization" yaml:"organization,omitempty"`
|
||||
Repository string `mapstructure:"repository" yaml:"repository,omitempty"`
|
||||
Version string `mapstructure:"version" yaml:"version,omitempty"`
|
||||
Organization string `mapstructure:"organization" yaml:"organization,omitempty"`
|
||||
Repository string `mapstructure:"repository" yaml:"repository,omitempty"`
|
||||
Version string `mapstructure:"version" yaml:"version,omitempty"`
|
||||
UseDefaultBranch bool `mapstructure:"use_default_branch" yaml:"use_default_branch"`
|
||||
|
||||
// Template Settings
|
||||
Theme string `mapstructure:"theme" yaml:"theme"`
|
||||
@@ -56,8 +57,9 @@ type AppConfig struct {
|
||||
RepoOverrides map[string]AppConfig `mapstructure:"repo_overrides" yaml:"repo_overrides,omitempty"`
|
||||
|
||||
// Behavior
|
||||
Verbose bool `mapstructure:"verbose" yaml:"verbose"`
|
||||
Quiet bool `mapstructure:"quiet" yaml:"quiet"`
|
||||
Verbose bool `mapstructure:"verbose" yaml:"verbose"`
|
||||
Quiet bool `mapstructure:"quiet" yaml:"quiet"`
|
||||
IgnoredDirectories []string `mapstructure:"ignored_directories" yaml:"ignored_directories,omitempty"`
|
||||
|
||||
// Default values for action.yml files (legacy)
|
||||
Defaults DefaultValues `mapstructure:"defaults" yaml:"defaults,omitempty"`
|
||||
@@ -147,7 +149,7 @@ func resolveTemplatePath(templatePath string) string {
|
||||
}
|
||||
|
||||
// Check if template is available in embedded filesystem first
|
||||
if templates_embed.IsEmbeddedTemplateAvailable(templatePath) {
|
||||
if templatesembed.IsEmbeddedTemplateAvailable(templatePath) {
|
||||
// Return a special marker to indicate this should use embedded templates
|
||||
// The actual template loading will handle embedded vs filesystem
|
||||
return templatePath
|
||||
@@ -213,9 +215,10 @@ func resolveThemeTemplate(theme string) string {
|
||||
func DefaultAppConfig() *AppConfig {
|
||||
return &AppConfig{
|
||||
// Repository Information (will be auto-detected)
|
||||
Organization: "",
|
||||
Repository: "",
|
||||
Version: "",
|
||||
Organization: "",
|
||||
Repository: "",
|
||||
Version: "",
|
||||
UseDefaultBranch: true, // Use detected default branch (main/master) in usage examples
|
||||
|
||||
// Template Settings
|
||||
Theme: "default", // default, github, gitlab, minimal, professional
|
||||
@@ -230,7 +233,7 @@ func DefaultAppConfig() *AppConfig {
|
||||
|
||||
// Workflow Requirements
|
||||
Permissions: map[string]string{},
|
||||
RunsOn: []string{"ubuntu-latest"},
|
||||
RunsOn: []string{appconstants.RunnerUbuntuLatest},
|
||||
|
||||
// Features
|
||||
AnalyzeDependencies: false,
|
||||
@@ -243,8 +246,9 @@ func DefaultAppConfig() *AppConfig {
|
||||
RepoOverrides: map[string]AppConfig{},
|
||||
|
||||
// Behavior
|
||||
Verbose: false,
|
||||
Quiet: false,
|
||||
Verbose: false,
|
||||
Quiet: false,
|
||||
IgnoredDirectories: appconstants.GetDefaultIgnoredDirectories(),
|
||||
|
||||
// Default values for action.yml files (legacy)
|
||||
Defaults: DefaultValues{
|
||||
@@ -294,7 +298,7 @@ func mergeStringFields(dst *AppConfig, src *AppConfig) {
|
||||
}
|
||||
|
||||
// mergeStringMap is a generic helper that merges a source map into a destination map.
|
||||
func mergeStringMap(dst *map[string]string, src map[string]string) {
|
||||
func mergeStringMap(src map[string]string, dst *map[string]string) {
|
||||
if len(src) == 0 {
|
||||
return
|
||||
}
|
||||
@@ -308,18 +312,24 @@ func mergeStringMap(dst *map[string]string, src map[string]string) {
|
||||
|
||||
// mergeMapFields merges map fields from src to dst if non-empty.
|
||||
func mergeMapFields(dst *AppConfig, src *AppConfig) {
|
||||
mergeStringMap(&dst.Permissions, src.Permissions)
|
||||
mergeStringMap(&dst.Variables, src.Variables)
|
||||
mergeStringMap(src.Permissions, &dst.Permissions)
|
||||
mergeStringMap(src.Variables, &dst.Variables)
|
||||
}
|
||||
|
||||
// mergeSliceFields merges slice fields from src to dst if non-empty.
|
||||
func mergeSliceFields(dst *AppConfig, src *AppConfig) {
|
||||
if len(src.RunsOn) > 0 {
|
||||
dst.RunsOn = make([]string, len(src.RunsOn))
|
||||
copy(dst.RunsOn, src.RunsOn)
|
||||
// copySliceIfNotEmpty copies src slice to dst if src is not empty.
|
||||
func copySliceIfNotEmpty(dst *[]string, src []string) {
|
||||
if len(src) > 0 {
|
||||
*dst = make([]string, len(src))
|
||||
copy(*dst, src)
|
||||
}
|
||||
}
|
||||
|
||||
func mergeSliceFields(dst *AppConfig, src *AppConfig) {
|
||||
copySliceIfNotEmpty(&dst.RunsOn, src.RunsOn)
|
||||
copySliceIfNotEmpty(&dst.IgnoredDirectories, src.IgnoredDirectories)
|
||||
}
|
||||
|
||||
// mergeBooleanFields merges boolean fields from src to dst if true.
|
||||
func mergeBooleanFields(dst *AppConfig, src *AppConfig) {
|
||||
if src.AnalyzeDependencies {
|
||||
@@ -334,6 +344,9 @@ func mergeBooleanFields(dst *AppConfig, src *AppConfig) {
|
||||
if src.Quiet {
|
||||
dst.Quiet = src.Quiet
|
||||
}
|
||||
if src.UseDefaultBranch {
|
||||
dst.UseDefaultBranch = src.UseDefaultBranch
|
||||
}
|
||||
}
|
||||
|
||||
// mergeSecurityFields merges security-sensitive fields if allowed.
|
||||
@@ -396,6 +409,29 @@ func DetectRepositoryName(repoRoot string) string {
|
||||
return info.GetRepositoryName()
|
||||
}
|
||||
|
||||
// loadAndMergeConfig is a helper that loads config from a directory and merges it.
|
||||
// Returns nil if dir is empty (no-op). Returns error if loading fails.
|
||||
func loadAndMergeConfig(
|
||||
config *AppConfig,
|
||||
dir string,
|
||||
loadFunc func(string) (*AppConfig, error),
|
||||
errorFormat string,
|
||||
allowTokens bool,
|
||||
) error {
|
||||
if dir == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
loadedConfig, err := loadFunc(dir)
|
||||
if err != nil {
|
||||
return fmt.Errorf(errorFormat, err)
|
||||
}
|
||||
|
||||
MergeConfigs(config, loadedConfig, allowTokens)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// LoadConfiguration loads configuration with multi-level hierarchy.
|
||||
func LoadConfiguration(configFile, repoRoot, actionDir string) (*AppConfig, error) {
|
||||
// 1. Start with defaults
|
||||
@@ -417,21 +453,15 @@ func LoadConfiguration(configFile, repoRoot, actionDir string) (*AppConfig, erro
|
||||
}
|
||||
|
||||
// 4. Load repository root ghreadme.yaml
|
||||
if repoRoot != "" {
|
||||
repoConfig, err := LoadRepoConfig(repoRoot)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf(appconstants.ErrFailedToLoadRepoConfig, err)
|
||||
}
|
||||
MergeConfigs(config, repoConfig, false) // No tokens in repo config
|
||||
if err := loadAndMergeConfig(config, repoRoot, LoadRepoConfig,
|
||||
appconstants.ErrFailedToLoadRepoConfig, false); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 5. Load action-specific config.yaml
|
||||
if actionDir != "" {
|
||||
actionConfig, err := LoadActionConfig(actionDir)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf(appconstants.ErrFailedToLoadActionConfig, err)
|
||||
}
|
||||
MergeConfigs(config, actionConfig, false) // No tokens in action config
|
||||
if err := loadAndMergeConfig(config, actionDir, LoadActionConfig,
|
||||
appconstants.ErrFailedToLoadActionConfig, false); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 6. Apply environment variable overrides for GitHub token
|
||||
|
||||
180
internal/config_helper_test.go
Normal file
180
internal/config_helper_test.go
Normal file
@@ -0,0 +1,180 @@
|
||||
package internal
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/google/go-github/v74/github"
|
||||
|
||||
"github.com/ivuorinen/gh-action-readme/appconstants"
|
||||
"github.com/ivuorinen/gh-action-readme/testutil"
|
||||
)
|
||||
|
||||
// TestAssertBooleanConfigFields_Helper tests the assertBooleanConfigFields helper.
|
||||
func TestAssertBooleanConfigFieldsHelper(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
got *AppConfig
|
||||
want *AppConfig
|
||||
}{
|
||||
{
|
||||
name: "all fields match",
|
||||
got: &AppConfig{
|
||||
AnalyzeDependencies: true,
|
||||
ShowSecurityInfo: false,
|
||||
Verbose: true,
|
||||
Quiet: false,
|
||||
UseDefaultBranch: true,
|
||||
},
|
||||
want: &AppConfig{
|
||||
AnalyzeDependencies: true,
|
||||
ShowSecurityInfo: false,
|
||||
Verbose: true,
|
||||
Quiet: false,
|
||||
UseDefaultBranch: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "all fields false",
|
||||
got: &AppConfig{
|
||||
AnalyzeDependencies: false,
|
||||
ShowSecurityInfo: false,
|
||||
Verbose: false,
|
||||
Quiet: false,
|
||||
UseDefaultBranch: false,
|
||||
},
|
||||
want: &AppConfig{
|
||||
AnalyzeDependencies: false,
|
||||
ShowSecurityInfo: false,
|
||||
Verbose: false,
|
||||
Quiet: false,
|
||||
UseDefaultBranch: false,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Call the helper - it will call t.Error if fields don't match
|
||||
// For matching cases, it should not error
|
||||
assertBooleanConfigFields(t, tt.got, tt.want)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestAssertGitHubClientValid_Helper tests the assertGitHubClientValid helper.
|
||||
func TestAssertGitHubClientValidHelper(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
client *GitHubClient
|
||||
expectedToken string
|
||||
}{
|
||||
{
|
||||
name: "valid client with token",
|
||||
client: &GitHubClient{
|
||||
Client: github.NewClient(nil),
|
||||
Token: "test-token-123",
|
||||
},
|
||||
expectedToken: "test-token-123",
|
||||
},
|
||||
{
|
||||
name: "valid client with empty token",
|
||||
client: &GitHubClient{
|
||||
Client: github.NewClient(nil),
|
||||
Token: "",
|
||||
},
|
||||
expectedToken: "",
|
||||
},
|
||||
{
|
||||
name: "valid client with github PAT",
|
||||
client: &GitHubClient{
|
||||
Client: github.NewClient(nil),
|
||||
Token: "ghp_1234567890abcdefghijklmnopqrstuvwxyzABCD",
|
||||
},
|
||||
expectedToken: "ghp_1234567890abcdefghijklmnopqrstuvwxyzABCD",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Call the helper - it will verify the client is valid
|
||||
// For valid clients, it should not error
|
||||
assertGitHubClientValid(t, tt.client, tt.expectedToken)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestRunTemplatePathTest_Helper tests the runTemplatePathTest helper.
|
||||
func TestRunTemplatePathTestHelper(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
setupFunc func(*testing.T) (string, func())
|
||||
checkFunc func(*testing.T, string)
|
||||
expectResult string
|
||||
}{
|
||||
{
|
||||
name: "absolute path setup",
|
||||
setupFunc: func(t *testing.T) (string, func()) {
|
||||
t.Helper()
|
||||
tmpDir := t.TempDir()
|
||||
templatePath := filepath.Join(tmpDir, "test.tmpl")
|
||||
|
||||
err := os.WriteFile(templatePath, []byte("test template"), appconstants.FilePermDefault)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to write template: %v", err)
|
||||
}
|
||||
|
||||
return templatePath, func() { /* Cleanup handled by t.TempDir() */ }
|
||||
},
|
||||
checkFunc: func(t *testing.T, result string) {
|
||||
t.Helper()
|
||||
if result == "" {
|
||||
t.Error(testutil.TestMsgExpectedNonEmpty)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "relative path setup",
|
||||
setupFunc: func(t *testing.T) (string, func()) {
|
||||
t.Helper()
|
||||
|
||||
return "templates/readme.tmpl", func() { /* No cleanup needed for relative path test */ }
|
||||
},
|
||||
checkFunc: func(t *testing.T, result string) {
|
||||
t.Helper()
|
||||
if result == "" {
|
||||
t.Error(testutil.TestMsgExpectedNonEmpty)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "nil checkFunc (just runs setup)",
|
||||
setupFunc: func(t *testing.T) (string, func()) {
|
||||
t.Helper()
|
||||
|
||||
return "test/path.tmpl", func() { /* No cleanup needed for nil checkFunc test */ }
|
||||
},
|
||||
checkFunc: nil, // No validation
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Call the helper - it runs setup, calls resolveTemplatePath, and validates
|
||||
runTemplatePathTest(t, tt.setupFunc, tt.checkFunc)
|
||||
})
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
284
internal/config_test_helper.go
Normal file
284
internal/config_test_helper.go
Normal file
@@ -0,0 +1,284 @@
|
||||
package internal
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/ivuorinen/gh-action-readme/testutil"
|
||||
)
|
||||
|
||||
// boolFields represents the boolean configuration fields used in merge tests.
|
||||
type boolFields struct {
|
||||
AnalyzeDependencies bool
|
||||
ShowSecurityInfo bool
|
||||
Verbose bool
|
||||
Quiet bool
|
||||
UseDefaultBranch bool
|
||||
}
|
||||
|
||||
// createBoolFieldMergeTest creates a test table entry for testing boolean field merging.
|
||||
// This helper reduces duplication by standardizing the creation of AppConfig test structures
|
||||
// with boolean fields.
|
||||
func createBoolFieldMergeTest(name string, dst, src, want boolFields) struct {
|
||||
name string
|
||||
dst *AppConfig
|
||||
src *AppConfig
|
||||
want *AppConfig
|
||||
} {
|
||||
return struct {
|
||||
name string
|
||||
dst *AppConfig
|
||||
src *AppConfig
|
||||
want *AppConfig
|
||||
}{
|
||||
name: name,
|
||||
dst: &AppConfig{
|
||||
AnalyzeDependencies: dst.AnalyzeDependencies,
|
||||
ShowSecurityInfo: dst.ShowSecurityInfo,
|
||||
Verbose: dst.Verbose,
|
||||
Quiet: dst.Quiet,
|
||||
UseDefaultBranch: dst.UseDefaultBranch,
|
||||
},
|
||||
src: &AppConfig{
|
||||
AnalyzeDependencies: src.AnalyzeDependencies,
|
||||
ShowSecurityInfo: src.ShowSecurityInfo,
|
||||
Verbose: src.Verbose,
|
||||
Quiet: src.Quiet,
|
||||
UseDefaultBranch: src.UseDefaultBranch,
|
||||
},
|
||||
want: &AppConfig{
|
||||
AnalyzeDependencies: want.AnalyzeDependencies,
|
||||
ShowSecurityInfo: want.ShowSecurityInfo,
|
||||
Verbose: want.Verbose,
|
||||
Quiet: want.Quiet,
|
||||
UseDefaultBranch: want.UseDefaultBranch,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// createGitRemoteTestCase creates a test table entry for git remote detection tests.
|
||||
// This helper reduces duplication for tests that set up a git repo with a remote config.
|
||||
func createGitRemoteTestCase(
|
||||
name, configContent, expectedResult, description string,
|
||||
) struct {
|
||||
name string
|
||||
setupFunc func(t *testing.T) string
|
||||
expectedResult string
|
||||
description string
|
||||
} {
|
||||
return struct {
|
||||
name string
|
||||
setupFunc func(t *testing.T) string
|
||||
expectedResult string
|
||||
description string
|
||||
}{
|
||||
name: name,
|
||||
setupFunc: func(t *testing.T) string {
|
||||
t.Helper()
|
||||
tmpDir, _ := testutil.TempDir(t)
|
||||
testutil.InitGitRepo(t, tmpDir)
|
||||
|
||||
if configContent != "" {
|
||||
configPath := filepath.Join(tmpDir, testutil.ConfigFieldGit, "config")
|
||||
testutil.WriteTestFile(t, configPath, configContent)
|
||||
}
|
||||
|
||||
return tmpDir
|
||||
},
|
||||
expectedResult: expectedResult,
|
||||
description: description,
|
||||
}
|
||||
}
|
||||
|
||||
// createTokenMergeTest creates a test table entry for testing token merging behavior.
|
||||
// This helper reduces duplication for the 4 token merge test cases.
|
||||
func createTokenMergeTest(
|
||||
name, dstToken, srcToken, wantToken string,
|
||||
allowTokens bool,
|
||||
) struct {
|
||||
name string
|
||||
dst *AppConfig
|
||||
src *AppConfig
|
||||
allowTokens bool
|
||||
want *AppConfig
|
||||
} {
|
||||
return struct {
|
||||
name string
|
||||
dst *AppConfig
|
||||
src *AppConfig
|
||||
allowTokens bool
|
||||
want *AppConfig
|
||||
}{
|
||||
name: name,
|
||||
dst: &AppConfig{GitHubToken: dstToken},
|
||||
src: &AppConfig{GitHubToken: srcToken},
|
||||
allowTokens: allowTokens,
|
||||
want: &AppConfig{GitHubToken: wantToken},
|
||||
}
|
||||
}
|
||||
|
||||
// createMapMergeTest creates a test table entry for testing map field merging (permissions/variables).
|
||||
// This helper reduces duplication for tests that merge map[string]string fields.
|
||||
func createMapMergeTest(
|
||||
name string,
|
||||
dstMap, srcMap, expectedMap map[string]string,
|
||||
isPermissions bool,
|
||||
) struct {
|
||||
name string
|
||||
dst *AppConfig
|
||||
src *AppConfig
|
||||
expected *AppConfig
|
||||
} {
|
||||
dst := &AppConfig{}
|
||||
src := &AppConfig{}
|
||||
expected := &AppConfig{}
|
||||
|
||||
if isPermissions {
|
||||
dst.Permissions = dstMap
|
||||
src.Permissions = srcMap
|
||||
expected.Permissions = expectedMap
|
||||
} else {
|
||||
dst.Variables = dstMap
|
||||
src.Variables = srcMap
|
||||
expected.Variables = expectedMap
|
||||
}
|
||||
|
||||
return struct {
|
||||
name string
|
||||
dst *AppConfig
|
||||
src *AppConfig
|
||||
expected *AppConfig
|
||||
}{
|
||||
name: name,
|
||||
dst: dst,
|
||||
src: src,
|
||||
expected: expected,
|
||||
}
|
||||
}
|
||||
|
||||
// ConfigHierarchySetup contains fixture paths for creating a multi-level config hierarchy.
|
||||
type ConfigHierarchySetup struct {
|
||||
GlobalFixture string // Fixture path for global config
|
||||
RepoFixture string // Fixture path for repo config
|
||||
ActionFixture string // Fixture path for action config
|
||||
}
|
||||
|
||||
// SetupConfigHierarchy creates a multi-level config hierarchy (global/repo/action).
|
||||
// Returns global config path, repo root, and action directory.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// globalPath, repoRoot, actionDir := SetupConfigHierarchy(t, tmpDir, ConfigHierarchySetup{
|
||||
// GlobalFixture: testutil.TestConfigGlobalDefault,
|
||||
// RepoFixture: testutil.TestConfigRepoSimple,
|
||||
// ActionFixture: testutil.TestConfigActionSimple,
|
||||
// })
|
||||
func SetupConfigHierarchy(
|
||||
t *testing.T,
|
||||
baseDir string,
|
||||
setup ConfigHierarchySetup,
|
||||
) (globalConfigPath, repoRoot, actionDir string) {
|
||||
t.Helper()
|
||||
// setupAndCreateConfigFixtures sets up config fixtures in a test directory.
|
||||
// It creates the repo directory structure unconditionally and populates config files
|
||||
// based on the provided setup.GlobalFixture, setup.RepoFixture, and
|
||||
// setup.ActionFixture. Returns globalConfigPath, repoRoot, and actionDir.
|
||||
|
||||
// Create global config
|
||||
if setup.GlobalFixture != "" {
|
||||
globalConfigDir := filepath.Join(baseDir, testutil.TestDirDotConfig, testutil.TestBinaryName)
|
||||
globalConfigPath = testutil.WriteFileInDir(
|
||||
t, globalConfigDir, testutil.TestFileConfigYAML,
|
||||
testutil.MustReadFixture(setup.GlobalFixture),
|
||||
)
|
||||
}
|
||||
|
||||
// Create repo config
|
||||
repoRoot = filepath.Join(baseDir, testutil.ConfigFieldRepo)
|
||||
if err := os.MkdirAll(repoRoot, 0o700); err != nil {
|
||||
t.Fatalf("failed to create repo directory: %v", err)
|
||||
}
|
||||
if setup.RepoFixture != "" {
|
||||
testutil.WriteFileInDir(
|
||||
t, repoRoot, testutil.TestFileGHReadmeYAML,
|
||||
testutil.MustReadFixture(setup.RepoFixture),
|
||||
)
|
||||
}
|
||||
|
||||
// Create action config
|
||||
if setup.ActionFixture != "" {
|
||||
actionDir = filepath.Join(repoRoot, testutil.ConfigFieldAction)
|
||||
testutil.WriteFileInDir(
|
||||
t, actionDir, testutil.TestFileConfigYAML,
|
||||
testutil.MustReadFixture(setup.ActionFixture),
|
||||
)
|
||||
} else {
|
||||
actionDir = repoRoot
|
||||
}
|
||||
|
||||
return globalConfigPath, repoRoot, actionDir
|
||||
}
|
||||
|
||||
// WriteConfigFixture writes a config fixture to a directory with standard config filename.
|
||||
// Returns the full path to the written config file.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// configPath := WriteConfigFixture(t, tmpDir, testutil.TestConfigGlobalDefault)
|
||||
func WriteConfigFixture(t *testing.T, dir, fixturePath string) string {
|
||||
t.Helper()
|
||||
|
||||
return testutil.WriteFileInDir(
|
||||
t, dir, testutil.TestFileConfigYAML,
|
||||
testutil.MustReadFixture(fixturePath),
|
||||
)
|
||||
}
|
||||
|
||||
// ExpectedConfig holds expected values for config field assertions.
|
||||
// Only non-zero values will be checked.
|
||||
type ExpectedConfig struct {
|
||||
Theme string
|
||||
OutputFormat string
|
||||
OutputDir string
|
||||
Template string
|
||||
Schema string
|
||||
Verbose bool
|
||||
Quiet bool
|
||||
GitHubToken string
|
||||
}
|
||||
|
||||
// AssertConfigFields asserts that config matches expected values for all non-empty fields.
|
||||
// Only checks fields that are set in expected (non-zero values).
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// AssertConfigFields(t, config, ExpectedConfig{
|
||||
// Theme: testutil.TestThemeDefault,
|
||||
// OutputFormat: "md",
|
||||
// Verbose: true,
|
||||
// })
|
||||
func AssertConfigFields(t *testing.T, config *AppConfig, expected ExpectedConfig) {
|
||||
t.Helper()
|
||||
if expected.Theme != "" {
|
||||
testutil.AssertEqual(t, expected.Theme, config.Theme)
|
||||
}
|
||||
if expected.OutputFormat != "" {
|
||||
testutil.AssertEqual(t, expected.OutputFormat, config.OutputFormat)
|
||||
}
|
||||
if expected.OutputDir != "" {
|
||||
testutil.AssertEqual(t, expected.OutputDir, config.OutputDir)
|
||||
}
|
||||
if expected.Template != "" {
|
||||
testutil.AssertEqual(t, expected.Template, config.Template)
|
||||
}
|
||||
if expected.Schema != "" {
|
||||
testutil.AssertEqual(t, expected.Schema, config.Schema)
|
||||
}
|
||||
// Always check booleans (they have meaningful zero values)
|
||||
testutil.AssertEqual(t, expected.Verbose, config.Verbose)
|
||||
testutil.AssertEqual(t, expected.Quiet, config.Quiet)
|
||||
if expected.GitHubToken != "" {
|
||||
testutil.AssertEqual(t, expected.GitHubToken, config.GitHubToken)
|
||||
}
|
||||
}
|
||||
35
internal/config_test_helpers.go
Normal file
35
internal/config_test_helpers.go
Normal file
@@ -0,0 +1,35 @@
|
||||
package internal
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/google/go-github/v74/github"
|
||||
|
||||
"github.com/ivuorinen/gh-action-readme/testutil"
|
||||
)
|
||||
|
||||
// assertGitHubClient validates GitHub client creation results.
|
||||
// This helper reduces test code duplication by centralizing
|
||||
// the client validation logic for github.Client instances.
|
||||
func assertGitHubClient(t *testing.T, client *github.Client, err error, expectError bool) {
|
||||
t.Helper()
|
||||
|
||||
if expectError {
|
||||
if err == nil {
|
||||
t.Error(testutil.TestErrNoErrorGotNone)
|
||||
}
|
||||
if client != nil {
|
||||
t.Error("expected nil client on error")
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// Success case
|
||||
if err != nil {
|
||||
t.Errorf(testutil.TestErrUnexpected, err)
|
||||
}
|
||||
if client == nil {
|
||||
t.Error("expected non-nil client")
|
||||
}
|
||||
}
|
||||
@@ -105,7 +105,7 @@ func (cl *ConfigurationLoader) ValidateConfiguration(config *AppConfig) error {
|
||||
}
|
||||
|
||||
// Validate output format
|
||||
validFormats := []string{"md", "html", "json", "asciidoc"}
|
||||
validFormats := appconstants.GetSupportedOutputFormats()
|
||||
if !containsString(validFormats, config.OutputFormat) {
|
||||
return fmt.Errorf("invalid output format '%s', must be one of: %s",
|
||||
config.OutputFormat, strings.Join(validFormats, ", "))
|
||||
@@ -196,34 +196,50 @@ func (cl *ConfigurationLoader) loadRepoOverrideStep(config *AppConfig, repoRoot
|
||||
cl.applyRepoOverrides(config, repoRoot)
|
||||
}
|
||||
|
||||
// loadRepoConfigStep loads repository root configuration.
|
||||
func (cl *ConfigurationLoader) loadRepoConfigStep(config *AppConfig, repoRoot string) error {
|
||||
if !cl.sources[appconstants.SourceRepoConfig] || repoRoot == "" {
|
||||
// loadConfigStep is a generic helper for loading and merging configuration from a specific source.
|
||||
func (cl *ConfigurationLoader) loadConfigStep(
|
||||
config *AppConfig,
|
||||
sourceName appconstants.ConfigurationSource,
|
||||
dirPath string,
|
||||
loadFunc func(string) (*AppConfig, error),
|
||||
errorFormat string,
|
||||
mergeTokens bool,
|
||||
) error {
|
||||
if !cl.sources[sourceName] || dirPath == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
repoConfig, err := cl.loadRepoConfig(repoRoot)
|
||||
loadedConfig, err := loadFunc(dirPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf(appconstants.ErrFailedToLoadRepoConfig, err)
|
||||
return fmt.Errorf(errorFormat, err)
|
||||
}
|
||||
cl.mergeConfigs(config, repoConfig, false) // No tokens in repo config
|
||||
cl.mergeConfigs(config, loadedConfig, mergeTokens)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// loadRepoConfigStep loads repository root configuration.
|
||||
func (cl *ConfigurationLoader) loadRepoConfigStep(config *AppConfig, repoRoot string) error {
|
||||
return cl.loadConfigStep(
|
||||
config,
|
||||
appconstants.SourceRepoConfig,
|
||||
repoRoot,
|
||||
cl.loadRepoConfig,
|
||||
appconstants.ErrFailedToLoadRepoConfig,
|
||||
false, // No tokens in repo config
|
||||
)
|
||||
}
|
||||
|
||||
// loadActionConfigStep loads action-specific configuration.
|
||||
func (cl *ConfigurationLoader) loadActionConfigStep(config *AppConfig, actionDir string) error {
|
||||
if !cl.sources[appconstants.SourceActionConfig] || actionDir == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
actionConfig, err := cl.loadActionConfig(actionDir)
|
||||
if err != nil {
|
||||
return fmt.Errorf(appconstants.ErrFailedToLoadActionConfig, err)
|
||||
}
|
||||
cl.mergeConfigs(config, actionConfig, false) // No tokens in action config
|
||||
|
||||
return nil
|
||||
return cl.loadConfigStep(
|
||||
config,
|
||||
appconstants.SourceActionConfig,
|
||||
actionDir,
|
||||
cl.loadActionConfig,
|
||||
appconstants.ErrFailedToLoadActionConfig,
|
||||
false, // No tokens in action config
|
||||
)
|
||||
}
|
||||
|
||||
// loadEnvironmentStep applies environment variable overrides.
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
178
internal/configuration_loader_test_helper.go
Normal file
178
internal/configuration_loader_test_helper.go
Normal file
@@ -0,0 +1,178 @@
|
||||
package internal
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/ivuorinen/gh-action-readme/appconstants"
|
||||
"github.com/ivuorinen/gh-action-readme/testutil"
|
||||
)
|
||||
|
||||
// repoOverrideTestCase defines the structure for repository override test cases.
|
||||
type repoOverrideTestCase struct {
|
||||
name string
|
||||
setupFunc func(t *testing.T) (config *AppConfig, repoRoot string)
|
||||
expectedTheme string
|
||||
expectedFormat string
|
||||
description string
|
||||
}
|
||||
|
||||
// runRepoOverrideTest executes a test case for repository override functionality.
|
||||
// This helper reduces duplication in TestConfigurationLoaderApplyRepoOverrides tests.
|
||||
func runRepoOverrideTest(t *testing.T, tc repoOverrideTestCase) {
|
||||
t.Helper()
|
||||
|
||||
config, repoRoot := tc.setupFunc(t)
|
||||
|
||||
loader := NewConfigurationLoader()
|
||||
loader.applyRepoOverrides(config, repoRoot)
|
||||
|
||||
// Verify expected values
|
||||
testutil.AssertEqual(t, tc.expectedTheme, config.Theme)
|
||||
testutil.AssertEqual(t, tc.expectedFormat, config.OutputFormat)
|
||||
}
|
||||
|
||||
// repoOverrideTestParams holds parameters for creating repo override test cases.
|
||||
type repoOverrideTestParams struct {
|
||||
name, remoteURL, overrideKey string
|
||||
overrideTheme, overrideFormat string
|
||||
expectedTheme, expectedFormat string
|
||||
description string
|
||||
}
|
||||
|
||||
// createRepoOverrideTestCase creates a repo override test case with git repo setup.
|
||||
// This helper reduces duplication when creating test cases that need git repositories.
|
||||
func createRepoOverrideTestCase(params repoOverrideTestParams) repoOverrideTestCase {
|
||||
return repoOverrideTestCase{
|
||||
name: params.name,
|
||||
setupFunc: func(t *testing.T) (*AppConfig, string) {
|
||||
t.Helper()
|
||||
tmpDir, _ := testutil.TempDir(t)
|
||||
|
||||
if params.remoteURL != "" {
|
||||
testutil.CreateGitRepoWithRemote(t, tmpDir, params.remoteURL)
|
||||
}
|
||||
|
||||
config := &AppConfig{
|
||||
Theme: testutil.TestThemeDefault,
|
||||
OutputFormat: "md",
|
||||
RepoOverrides: map[string]AppConfig{
|
||||
params.overrideKey: {
|
||||
Theme: params.overrideTheme,
|
||||
OutputFormat: params.overrideFormat,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
return config, tmpDir
|
||||
},
|
||||
expectedTheme: params.expectedTheme,
|
||||
expectedFormat: params.expectedFormat,
|
||||
description: params.description,
|
||||
}
|
||||
}
|
||||
|
||||
// configLoaderTestCase defines the structure for configuration loader test cases.
|
||||
type configLoaderTestCase struct {
|
||||
name string
|
||||
setupFunc func(t *testing.T) string
|
||||
expectError bool
|
||||
checkFunc func(t *testing.T, config *AppConfig)
|
||||
description string
|
||||
}
|
||||
|
||||
// runConfigLoaderTest executes a test case for configuration loading functionality.
|
||||
// This helper reduces duplication between LoadGlobalConfig and loadActionConfig tests.
|
||||
func runConfigLoaderTest(
|
||||
t *testing.T,
|
||||
tc configLoaderTestCase,
|
||||
loadFunc func(loader *ConfigurationLoader, path string) (*AppConfig, error),
|
||||
) {
|
||||
t.Helper()
|
||||
t.Parallel()
|
||||
|
||||
path := tc.setupFunc(t)
|
||||
|
||||
loader := NewConfigurationLoader()
|
||||
config, err := loadFunc(loader, path)
|
||||
|
||||
if tc.expectError {
|
||||
testutil.AssertError(t, err)
|
||||
} else {
|
||||
testutil.AssertNoError(t, err)
|
||||
|
||||
if tc.checkFunc != nil {
|
||||
tc.checkFunc(t, config)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// checkThemeAndFormat is a helper that creates a checkFunc for verifying theme and output format.
|
||||
// This reduces duplication in test cases that only need to verify these two fields.
|
||||
func checkThemeAndFormat(expectedTheme, expectedFormat string) func(t *testing.T, config *AppConfig) {
|
||||
return func(t *testing.T, config *AppConfig) {
|
||||
t.Helper()
|
||||
testutil.AssertEqual(t, expectedTheme, config.Theme)
|
||||
testutil.AssertEqual(t, expectedFormat, config.OutputFormat)
|
||||
}
|
||||
}
|
||||
|
||||
// AssertSourceEnabled fails the test if the specified source is not in the enabled sources list.
|
||||
// This helper reduces duplication in tests that verify configuration sources are enabled.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// AssertSourceEnabled(t, enabledSources, appconstants.ConfigSourceGlobal)
|
||||
func AssertSourceEnabled(
|
||||
t *testing.T,
|
||||
sources []appconstants.ConfigurationSource,
|
||||
expectedSource appconstants.ConfigurationSource,
|
||||
) {
|
||||
t.Helper()
|
||||
for _, source := range sources {
|
||||
if source == expectedSource {
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Errorf("expected source %s to be enabled, but it was not found", expectedSource)
|
||||
}
|
||||
|
||||
// AssertSourceDisabled fails the test if the specified source is in the enabled sources list.
|
||||
// This helper reduces duplication in tests that verify configuration sources are disabled.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// AssertSourceDisabled(t, enabledSources, appconstants.ConfigSourceGlobal)
|
||||
func AssertSourceDisabled(
|
||||
t *testing.T,
|
||||
sources []appconstants.ConfigurationSource,
|
||||
expectedSource appconstants.ConfigurationSource,
|
||||
) {
|
||||
t.Helper()
|
||||
for _, source := range sources {
|
||||
if source == expectedSource {
|
||||
t.Errorf("expected source %s to be disabled, but it was found", expectedSource)
|
||||
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// AssertAllSourcesEnabled fails the test if any of the expected sources are not in the enabled sources list.
|
||||
// This helper reduces duplication in tests that verify multiple configuration sources are enabled.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// AssertAllSourcesEnabled(t, enabledSources,
|
||||
// appconstants.ConfigSourceGlobal,
|
||||
// appconstants.ConfigSourceRepo,
|
||||
// appconstants.ConfigSourceAction)
|
||||
func AssertAllSourcesEnabled(
|
||||
t *testing.T,
|
||||
sources []appconstants.ConfigurationSource,
|
||||
expectedSources ...appconstants.ConfigurationSource,
|
||||
) {
|
||||
t.Helper()
|
||||
for _, expected := range expectedSources {
|
||||
AssertSourceEnabled(t, sources, expected)
|
||||
}
|
||||
}
|
||||
@@ -605,19 +605,7 @@ func (a *Analyzer) updateActionFile(filePath string, updates []PinnedUpdate) err
|
||||
|
||||
// Apply updates to content
|
||||
lines := strings.Split(string(content), "\n")
|
||||
for _, update := range updates {
|
||||
// Find and replace the uses line
|
||||
for i, line := range lines {
|
||||
if strings.Contains(line, update.OldUses) {
|
||||
// Replace the uses statement while preserving indentation
|
||||
indent := strings.Repeat(" ", len(line)-len(strings.TrimLeft(line, " ")))
|
||||
lines[i] = indent + appconstants.UsesFieldPrefix + update.NewUses
|
||||
update.LineNumber = i + 1 // Store line number for reference
|
||||
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
applyUpdatesToLines(lines, updates)
|
||||
|
||||
// Write updated content
|
||||
updatedContent := strings.Join(lines, "\n")
|
||||
@@ -625,7 +613,44 @@ func (a *Analyzer) updateActionFile(filePath string, updates []PinnedUpdate) err
|
||||
return fmt.Errorf("failed to write updated file: %w", err)
|
||||
}
|
||||
|
||||
// Validate the updated file by trying to parse it
|
||||
// Validate and rollback on failure
|
||||
if err := a.validateAndRollbackOnFailure(filePath, backupPath); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Remove backup on success
|
||||
_ = os.Remove(backupPath)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// applyUpdatesToLines applies all updates to the file lines in place.
|
||||
// Preserves indentation and YAML list markers.
|
||||
func applyUpdatesToLines(lines []string, updates []PinnedUpdate) {
|
||||
for _, update := range updates {
|
||||
for i, line := range lines {
|
||||
if !strings.Contains(line, update.OldUses) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Preserve both indentation AND list markers
|
||||
trimmed := strings.TrimLeft(line, " \t")
|
||||
indent := strings.Repeat(" ", len(line)-len(trimmed))
|
||||
|
||||
// Check if this is a list item (starts with "- ")
|
||||
listMarker := ""
|
||||
if strings.HasPrefix(trimmed, "- ") {
|
||||
listMarker = "- "
|
||||
}
|
||||
|
||||
// Reconstruct: indent + list marker + uses field
|
||||
lines[i] = indent + listMarker + appconstants.UsesFieldPrefix + update.NewUses
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// validateAndRollbackOnFailure validates the action file and rolls back changes on failure.
|
||||
func (a *Analyzer) validateAndRollbackOnFailure(filePath, backupPath string) error {
|
||||
if err := a.validateActionFile(filePath); err != nil {
|
||||
// Rollback on validation failure
|
||||
if rollbackErr := os.Rename(backupPath, filePath); rollbackErr != nil {
|
||||
@@ -635,17 +660,60 @@ func (a *Analyzer) updateActionFile(filePath string, updates []PinnedUpdate) err
|
||||
return fmt.Errorf("validation failed, rolled back changes: %w", err)
|
||||
}
|
||||
|
||||
// Remove backup on success
|
||||
_ = os.Remove(backupPath)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateActionFile validates that an action.yml file is still valid after updates.
|
||||
// validateActionFile validates that an action.yml file conforms to GitHub Actions schema.
|
||||
// Schema reference: https://www.schemastore.org/github-action.json
|
||||
func (a *Analyzer) validateActionFile(filePath string) error {
|
||||
_, err := a.parseCompositeAction(filePath)
|
||||
// Parse to check YAML syntax
|
||||
action, err := a.parseCompositeAction(filePath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return err
|
||||
// Validate required fields per GitHub Actions schema
|
||||
if action.Name == "" {
|
||||
return errors.New("validation failed: missing required field 'name'")
|
||||
}
|
||||
if action.Description == "" {
|
||||
return errors.New("validation failed: missing required field 'description'")
|
||||
}
|
||||
if action.Runs.Using == "" {
|
||||
return errors.New("validation failed: missing required field 'runs.using'")
|
||||
}
|
||||
|
||||
// Validate 'using' field value against GitHub Actions specification
|
||||
// Valid runtimes: node12, node16, node20, node24, docker, composite
|
||||
// Reference: https://docs.github.com/en/actions/creating-actions
|
||||
validRuntimes := []string{
|
||||
"node12",
|
||||
"node16",
|
||||
"node20",
|
||||
"node24",
|
||||
"docker",
|
||||
"composite",
|
||||
}
|
||||
|
||||
validUsing := false
|
||||
runtime := strings.TrimSpace(strings.ToLower(action.Runs.Using))
|
||||
for _, valid := range validRuntimes {
|
||||
if runtime == valid {
|
||||
validUsing = true
|
||||
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !validUsing {
|
||||
return fmt.Errorf(
|
||||
"validation failed: invalid value for 'runs.using': %s (valid: %s)",
|
||||
action.Runs.Using,
|
||||
strings.Join(validRuntimes, ", "),
|
||||
)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// enrichWithGitHubData fetches additional information from GitHub API.
|
||||
|
||||
@@ -16,47 +16,116 @@ import (
|
||||
"github.com/ivuorinen/gh-action-readme/testutil"
|
||||
)
|
||||
|
||||
func TestAnalyzer_AnalyzeActionFile(t *testing.T) {
|
||||
// analyzeActionFileTestCase describes a single test case for AnalyzeActionFile.
|
||||
type analyzeActionFileTestCase struct {
|
||||
name string
|
||||
actionYML string
|
||||
expectError bool
|
||||
expectDeps bool
|
||||
expectedLen int
|
||||
expectedDeps []string
|
||||
}
|
||||
|
||||
// runAnalyzeActionFileTest executes a single test case with setup, analysis, and validation.
|
||||
func runAnalyzeActionFileTest(t *testing.T, tt analyzeActionFileTestCase) {
|
||||
t.Helper()
|
||||
|
||||
tmpDir, cleanup := testutil.TempDir(t)
|
||||
defer cleanup()
|
||||
|
||||
actionPath := filepath.Join(tmpDir, appconstants.ActionFileNameYML)
|
||||
testutil.WriteTestFile(t, actionPath, tt.actionYML)
|
||||
|
||||
mockResponses := testutil.MockGitHubResponses()
|
||||
githubClient := testutil.MockGitHubClient(mockResponses)
|
||||
cacheInstance, _ := cache.NewCache(cache.DefaultConfig())
|
||||
|
||||
analyzer := &Analyzer{
|
||||
GitHubClient: githubClient,
|
||||
Cache: NewCacheAdapter(cacheInstance),
|
||||
}
|
||||
|
||||
deps, err := analyzer.AnalyzeActionFile(actionPath)
|
||||
|
||||
if tt.expectError {
|
||||
testutil.AssertError(t, err)
|
||||
|
||||
return
|
||||
}
|
||||
testutil.AssertNoError(t, err)
|
||||
|
||||
validateAnalyzedDependencies(t, tt, deps)
|
||||
}
|
||||
|
||||
// validateAnalyzedDependencies checks that analyzed dependencies match expectations.
|
||||
func validateAnalyzedDependencies(t *testing.T, tt analyzeActionFileTestCase, deps []Dependency) {
|
||||
t.Helper()
|
||||
|
||||
if tt.expectDeps {
|
||||
validateExpectedDeps(t, tt, deps)
|
||||
} else if len(deps) != 0 {
|
||||
t.Errorf("expected no dependencies, got %d", len(deps))
|
||||
}
|
||||
}
|
||||
|
||||
// validateExpectedDeps validates dependencies when deps are expected.
|
||||
func validateExpectedDeps(t *testing.T, tt analyzeActionFileTestCase, deps []Dependency) {
|
||||
t.Helper()
|
||||
|
||||
if len(deps) != tt.expectedLen {
|
||||
t.Errorf("expected %d dependencies, got %d", tt.expectedLen, len(deps))
|
||||
}
|
||||
|
||||
if tt.expectedDeps == nil {
|
||||
return
|
||||
}
|
||||
|
||||
for i, expectedDep := range tt.expectedDeps {
|
||||
if i >= len(deps) {
|
||||
t.Errorf("expected dependency %s but got fewer dependencies", expectedDep)
|
||||
|
||||
continue
|
||||
}
|
||||
if !strings.Contains(deps[i].Name+"@"+deps[i].Version, expectedDep) {
|
||||
t.Errorf("expected dependency %s, got %s@%s", expectedDep, deps[i].Name, deps[i].Version)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnalyzerAnalyzeActionFile(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
actionYML string
|
||||
expectError bool
|
||||
expectDeps bool
|
||||
expectedLen int
|
||||
expectedDeps []string
|
||||
}{
|
||||
tests := []analyzeActionFileTestCase{
|
||||
{
|
||||
name: "simple action - no dependencies",
|
||||
actionYML: testutil.MustReadFixture(appconstants.TestFixtureJavaScriptSimple),
|
||||
actionYML: testutil.MustReadFixture(testutil.TestFixtureJavaScriptSimple),
|
||||
expectError: false,
|
||||
expectDeps: false,
|
||||
expectedLen: 0,
|
||||
},
|
||||
{
|
||||
name: "composite action with dependencies",
|
||||
actionYML: testutil.MustReadFixture(appconstants.TestFixtureCompositeWithDeps),
|
||||
actionYML: testutil.MustReadFixture(testutil.TestFixtureCompositeWithDeps),
|
||||
expectError: false,
|
||||
expectDeps: true,
|
||||
expectedLen: 5, // 3 action dependencies + 2 shell script dependencies
|
||||
expectedDeps: []string{"actions/checkout@v4", "actions/setup-node@v4", "actions/setup-python@v4"},
|
||||
expectedLen: 5,
|
||||
expectedDeps: []string{testutil.TestActionCheckoutV4, "actions/setup-node@v4", "actions/setup-python@v4"},
|
||||
},
|
||||
{
|
||||
name: "docker action - no step dependencies",
|
||||
actionYML: testutil.MustReadFixture(appconstants.TestFixtureDockerBasic),
|
||||
actionYML: testutil.MustReadFixture(testutil.TestFixtureDockerBasic),
|
||||
expectError: false,
|
||||
expectDeps: false,
|
||||
expectedLen: 0,
|
||||
},
|
||||
{
|
||||
name: "invalid action file",
|
||||
actionYML: testutil.MustReadFixture(appconstants.TestFixtureInvalidInvalidUsing),
|
||||
name: testutil.TestCaseNameInvalidActionFile,
|
||||
actionYML: testutil.MustReadFixture(testutil.TestFixtureInvalidInvalidUsing),
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
name: "minimal action - no dependencies",
|
||||
actionYML: testutil.MustReadFixture("minimal-action.yml"),
|
||||
actionYML: testutil.MustReadFixture(testutil.TestFixtureMinimalAction),
|
||||
expectError: false,
|
||||
expectDeps: false,
|
||||
expectedLen: 0,
|
||||
@@ -66,62 +135,12 @@ func TestAnalyzer_AnalyzeActionFile(t *testing.T) {
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Create temporary action file
|
||||
tmpDir, cleanup := testutil.TempDir(t)
|
||||
defer cleanup()
|
||||
|
||||
actionPath := filepath.Join(tmpDir, appconstants.TestPathActionYML)
|
||||
testutil.WriteTestFile(t, actionPath, tt.actionYML)
|
||||
|
||||
// Create analyzer with mock GitHub client
|
||||
mockResponses := testutil.MockGitHubResponses()
|
||||
githubClient := testutil.MockGitHubClient(mockResponses)
|
||||
cacheInstance, _ := cache.NewCache(cache.DefaultConfig())
|
||||
|
||||
analyzer := &Analyzer{
|
||||
GitHubClient: githubClient,
|
||||
Cache: NewCacheAdapter(cacheInstance),
|
||||
}
|
||||
|
||||
// Analyze the action file
|
||||
deps, err := analyzer.AnalyzeActionFile(actionPath)
|
||||
|
||||
// Check error expectation
|
||||
if tt.expectError {
|
||||
testutil.AssertError(t, err)
|
||||
|
||||
return
|
||||
}
|
||||
testutil.AssertNoError(t, err)
|
||||
|
||||
// Check dependencies
|
||||
if tt.expectDeps {
|
||||
if len(deps) != tt.expectedLen {
|
||||
t.Errorf("expected %d dependencies, got %d", tt.expectedLen, len(deps))
|
||||
}
|
||||
|
||||
// Check specific dependencies if provided
|
||||
if tt.expectedDeps != nil {
|
||||
for i, expectedDep := range tt.expectedDeps {
|
||||
if i >= len(deps) {
|
||||
t.Errorf("expected dependency %s but got fewer dependencies", expectedDep)
|
||||
|
||||
continue
|
||||
}
|
||||
if !strings.Contains(deps[i].Name+"@"+deps[i].Version, expectedDep) {
|
||||
t.Errorf("expected dependency %s, got %s@%s", expectedDep, deps[i].Name, deps[i].Version)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if len(deps) != 0 {
|
||||
t.Errorf("expected no dependencies, got %d", len(deps))
|
||||
}
|
||||
runAnalyzeActionFileTest(t, tt)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnalyzer_ParseUsesStatement(t *testing.T) {
|
||||
func TestAnalyzerParseUsesStatement(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
@@ -133,8 +152,8 @@ func TestAnalyzer_ParseUsesStatement(t *testing.T) {
|
||||
expectedType VersionType
|
||||
}{
|
||||
{
|
||||
name: "semantic version",
|
||||
uses: "actions/checkout@v4",
|
||||
name: testutil.TestCaseNameSemanticVersion,
|
||||
uses: testutil.TestActionCheckoutV4,
|
||||
expectedOwner: "actions",
|
||||
expectedRepo: "checkout",
|
||||
expectedVersion: "v4",
|
||||
@@ -149,11 +168,11 @@ func TestAnalyzer_ParseUsesStatement(t *testing.T) {
|
||||
expectedType: SemanticVersion,
|
||||
},
|
||||
{
|
||||
name: "commit SHA",
|
||||
name: testutil.TestCaseNameCommitSHA,
|
||||
uses: "actions/checkout@8f4b7f84bd579b95d7f0b90f8d8b6e5d9b8a7f6e",
|
||||
expectedOwner: "actions",
|
||||
expectedRepo: "checkout",
|
||||
expectedVersion: "8f4b7f84bd579b95d7f0b90f8d8b6e5d9b8a7f6e",
|
||||
expectedVersion: testutil.TestSHAForTesting,
|
||||
expectedType: CommitSHA,
|
||||
},
|
||||
{
|
||||
@@ -182,7 +201,7 @@ func TestAnalyzer_ParseUsesStatement(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnalyzer_VersionChecking(t *testing.T) {
|
||||
func TestAnalyzerVersionChecking(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
@@ -208,7 +227,7 @@ func TestAnalyzer_VersionChecking(t *testing.T) {
|
||||
},
|
||||
{
|
||||
name: "commit SHA full",
|
||||
version: "8f4b7f84bd579b95d7f0b90f8d8b6e5d9b8a7f6e",
|
||||
version: testutil.TestSHAForTesting,
|
||||
isPinned: true,
|
||||
isCommitSHA: true,
|
||||
isSemantic: false,
|
||||
@@ -253,7 +272,7 @@ func TestAnalyzer_VersionChecking(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnalyzer_GetLatestVersion(t *testing.T) {
|
||||
func TestAnalyzerGetLatestVersion(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Create mock GitHub client with test responses
|
||||
@@ -278,15 +297,15 @@ func TestAnalyzer_GetLatestVersion(t *testing.T) {
|
||||
name: "valid repository",
|
||||
owner: "actions",
|
||||
repo: "checkout",
|
||||
expectedVersion: "v4.1.1",
|
||||
expectedSHA: "8f4b7f84bd579b95d7f0b90f8d8b6e5d9b8a7f6e",
|
||||
expectedVersion: testutil.TestVersionV4_1_1,
|
||||
expectedSHA: testutil.TestSHAForTesting,
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "another valid repository",
|
||||
owner: "actions",
|
||||
repo: "setup-node",
|
||||
expectedVersion: "v4.0.0",
|
||||
expectedVersion: testutil.TestVersionV4_0_0,
|
||||
expectedSHA: "1a4e6d7c9f8e5b2a3c4d5e6f7a8b9c0d1e2f3a4b",
|
||||
expectError: false,
|
||||
},
|
||||
@@ -311,7 +330,7 @@ func TestAnalyzer_GetLatestVersion(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnalyzer_CheckOutdated(t *testing.T) {
|
||||
func TestAnalyzerCheckOutdated(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Create mock GitHub client
|
||||
@@ -327,8 +346,8 @@ func TestAnalyzer_CheckOutdated(t *testing.T) {
|
||||
// Create test dependencies
|
||||
dependencies := []Dependency{
|
||||
{
|
||||
Name: "actions/checkout",
|
||||
Uses: "actions/checkout@v3",
|
||||
Name: testutil.TestActionCheckout,
|
||||
Uses: testutil.TestActionCheckoutV3,
|
||||
Version: "v3",
|
||||
IsPinned: false,
|
||||
VersionType: SemanticVersion,
|
||||
@@ -337,7 +356,7 @@ func TestAnalyzer_CheckOutdated(t *testing.T) {
|
||||
{
|
||||
Name: "actions/setup-node",
|
||||
Uses: "actions/setup-node@v4.0.0",
|
||||
Version: "v4.0.0",
|
||||
Version: testutil.TestVersionV4_0_0,
|
||||
IsPinned: true,
|
||||
VersionType: SemanticVersion,
|
||||
Description: "Setup Node.js",
|
||||
@@ -354,9 +373,9 @@ func TestAnalyzer_CheckOutdated(t *testing.T) {
|
||||
|
||||
found := false
|
||||
for _, dep := range outdated {
|
||||
if dep.Current.Name == "actions/checkout" && dep.Current.Version == "v3" {
|
||||
if dep.Current.Name == testutil.TestActionCheckout && dep.Current.Version == "v3" {
|
||||
found = true
|
||||
if dep.LatestVersion != "v4.1.1" {
|
||||
if dep.LatestVersion != testutil.TestVersionV4_1_1 {
|
||||
t.Errorf("expected latest version v4.1.1, got %s", dep.LatestVersion)
|
||||
}
|
||||
if dep.UpdateType != "major" {
|
||||
@@ -370,7 +389,7 @@ func TestAnalyzer_CheckOutdated(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnalyzer_CompareVersions(t *testing.T) {
|
||||
func TestAnalyzerCompareVersions(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
analyzer := &Analyzer{}
|
||||
@@ -384,31 +403,31 @@ func TestAnalyzer_CompareVersions(t *testing.T) {
|
||||
{
|
||||
name: "major version difference",
|
||||
current: "v3.0.0",
|
||||
latest: "v4.0.0",
|
||||
latest: testutil.TestVersionV4_0_0,
|
||||
expectedType: "major",
|
||||
},
|
||||
{
|
||||
name: "minor version difference",
|
||||
current: "v4.0.0",
|
||||
current: testutil.TestVersionV4_0_0,
|
||||
latest: "v4.1.0",
|
||||
expectedType: "minor",
|
||||
},
|
||||
{
|
||||
name: "patch version difference",
|
||||
current: "v4.1.0",
|
||||
latest: "v4.1.1",
|
||||
latest: testutil.TestVersionV4_1_1,
|
||||
expectedType: "patch",
|
||||
},
|
||||
{
|
||||
name: "no difference",
|
||||
current: "v4.1.1",
|
||||
latest: "v4.1.1",
|
||||
current: testutil.TestVersionV4_1_1,
|
||||
latest: testutil.TestVersionV4_1_1,
|
||||
expectedType: "none",
|
||||
},
|
||||
{
|
||||
name: "floating to specific",
|
||||
current: "v4",
|
||||
latest: "v4.1.1",
|
||||
latest: testutil.TestVersionV4_1_1,
|
||||
expectedType: "patch",
|
||||
},
|
||||
}
|
||||
@@ -423,16 +442,16 @@ func TestAnalyzer_CompareVersions(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnalyzer_GeneratePinnedUpdate(t *testing.T) {
|
||||
func TestAnalyzerGeneratePinnedUpdate(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tmpDir, cleanup := testutil.TempDir(t)
|
||||
defer cleanup()
|
||||
|
||||
// Create a test action file with composite steps
|
||||
actionContent := testutil.MustReadFixture(appconstants.TestFixtureTestCompositeAction)
|
||||
actionContent := testutil.MustReadFixture(testutil.TestFixtureTestCompositeAction)
|
||||
|
||||
actionPath := filepath.Join(tmpDir, appconstants.TestPathActionYML)
|
||||
actionPath := filepath.Join(tmpDir, appconstants.ActionFileNameYML)
|
||||
testutil.WriteTestFile(t, actionPath, actionContent)
|
||||
|
||||
// Create analyzer
|
||||
@@ -447,8 +466,8 @@ func TestAnalyzer_GeneratePinnedUpdate(t *testing.T) {
|
||||
|
||||
// Create test dependency
|
||||
dep := Dependency{
|
||||
Name: "actions/checkout",
|
||||
Uses: "actions/checkout@v3",
|
||||
Name: testutil.TestActionCheckout,
|
||||
Uses: testutil.TestActionCheckoutV3,
|
||||
Version: "v3",
|
||||
IsPinned: false,
|
||||
VersionType: SemanticVersion,
|
||||
@@ -459,21 +478,21 @@ func TestAnalyzer_GeneratePinnedUpdate(t *testing.T) {
|
||||
update, err := analyzer.GeneratePinnedUpdate(
|
||||
actionPath,
|
||||
dep,
|
||||
"v4.1.1",
|
||||
"8f4b7f84bd579b95d7f0b90f8d8b6e5d9b8a7f6e",
|
||||
testutil.TestVersionV4_1_1,
|
||||
testutil.TestSHAForTesting,
|
||||
)
|
||||
|
||||
testutil.AssertNoError(t, err)
|
||||
|
||||
// Verify update details
|
||||
testutil.AssertEqual(t, actionPath, update.FilePath)
|
||||
testutil.AssertEqual(t, "actions/checkout@v3", update.OldUses)
|
||||
testutil.AssertEqual(t, testutil.TestActionCheckoutV3, update.OldUses)
|
||||
testutil.AssertStringContains(t, update.NewUses, "actions/checkout@8f4b7f84bd579b95d7f0b90f8d8b6e5d9b8a7f6e")
|
||||
testutil.AssertStringContains(t, update.NewUses, "# v4.1.1")
|
||||
testutil.AssertEqual(t, "major", update.UpdateType)
|
||||
}
|
||||
|
||||
func TestAnalyzer_WithCache(t *testing.T) {
|
||||
func TestAnalyzerWithCache(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Test that caching works properly
|
||||
@@ -499,7 +518,7 @@ func TestAnalyzer_WithCache(t *testing.T) {
|
||||
testutil.AssertEqual(t, sha1, sha2)
|
||||
}
|
||||
|
||||
func TestAnalyzer_RateLimitHandling(t *testing.T) {
|
||||
func TestAnalyzerRateLimitHandling(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Create mock client that returns rate limit error
|
||||
@@ -518,7 +537,7 @@ func TestAnalyzer_RateLimitHandling(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
client := github.NewClient(&http.Client{Transport: &mockTransport{client: mockClient}})
|
||||
client := github.NewClient(&http.Client{Transport: &testutil.MockTransport{Client: mockClient}})
|
||||
cacheInstance, _ := cache.NewCache(cache.DefaultConfig())
|
||||
|
||||
analyzer := &Analyzer{
|
||||
@@ -539,7 +558,7 @@ func TestAnalyzer_RateLimitHandling(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnalyzer_WithoutGitHubClient(t *testing.T) {
|
||||
func TestAnalyzerWithoutGitHubClient(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Test graceful degradation when GitHub client is not available
|
||||
@@ -551,8 +570,8 @@ func TestAnalyzer_WithoutGitHubClient(t *testing.T) {
|
||||
tmpDir, cleanup := testutil.TempDir(t)
|
||||
defer cleanup()
|
||||
|
||||
actionPath := filepath.Join(tmpDir, appconstants.TestPathActionYML)
|
||||
testutil.WriteTestFile(t, actionPath, testutil.MustReadFixture(appconstants.TestFixtureCompositeBasic))
|
||||
actionPath := filepath.Join(tmpDir, appconstants.ActionFileNameYML)
|
||||
testutil.WriteTestFile(t, actionPath, testutil.MustReadFixture(testutil.TestFixtureCompositeBasic))
|
||||
|
||||
deps, err := analyzer.AnalyzeActionFile(actionPath)
|
||||
|
||||
@@ -569,15 +588,6 @@ func TestAnalyzer_WithoutGitHubClient(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// mockTransport wraps our mock HTTP client for GitHub client.
|
||||
type mockTransport struct {
|
||||
client *testutil.MockHTTPClient
|
||||
}
|
||||
|
||||
func (t *mockTransport) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
return t.client.Do(req)
|
||||
}
|
||||
|
||||
// TestNewAnalyzer tests the analyzer constructor.
|
||||
func TestNewAnalyzer(t *testing.T) {
|
||||
t.Parallel()
|
||||
@@ -654,3 +664,125 @@ func TestNewAnalyzer(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestNoOpCache tests the no-op cache implementation.
|
||||
func TestNoOpCache(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
noc := NewNoOpCache()
|
||||
if noc == nil {
|
||||
t.Fatal("NewNoOpCache() returned nil")
|
||||
}
|
||||
|
||||
// Test Get - should always return false
|
||||
val, ok := noc.Get(testutil.CacheTestKey)
|
||||
if ok {
|
||||
t.Error("NoOpCache.Get() should return false")
|
||||
}
|
||||
if val != nil {
|
||||
t.Error("NoOpCache.Get() should return nil value")
|
||||
}
|
||||
|
||||
// Test Set - should not error
|
||||
err := noc.Set(testutil.CacheTestKey, testutil.CacheTestValue)
|
||||
if err != nil {
|
||||
t.Errorf("NoOpCache.Set() returned error: %v", err)
|
||||
}
|
||||
|
||||
// Test SetWithTTL - should not error
|
||||
err = noc.SetWithTTL(testutil.CacheTestKey, testutil.CacheTestValue, time.Hour)
|
||||
if err != nil {
|
||||
t.Errorf("NoOpCache.SetWithTTL() returned error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCacheAdapterSet tests the cache adapter Set method.
|
||||
func TestCacheAdapterSet(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
c, err := cache.NewCache(cache.DefaultConfig())
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create cache: %v", err)
|
||||
}
|
||||
defer testutil.CleanupCache(t, c)()
|
||||
|
||||
adapter := NewCacheAdapter(c)
|
||||
|
||||
// Test Set
|
||||
err = adapter.Set(testutil.CacheTestKey, testutil.CacheTestValue)
|
||||
if err != nil {
|
||||
t.Errorf("CacheAdapter.Set() returned error: %v", err)
|
||||
}
|
||||
|
||||
// Verify value was set
|
||||
val, ok := adapter.Get(testutil.CacheTestKey)
|
||||
if !ok {
|
||||
t.Error("CacheAdapter.Get() should return true after Set")
|
||||
}
|
||||
if val != testutil.CacheTestValue {
|
||||
t.Errorf("CacheAdapter.Get() = %v, want %q", val, testutil.CacheTestValue)
|
||||
}
|
||||
}
|
||||
|
||||
// TestIsCompositeAction tests composite action detection.
|
||||
func TestIsCompositeAction(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
fixture string
|
||||
want bool
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: testutil.TestCaseNameCompositeAction,
|
||||
fixture: "composite-action.yml",
|
||||
want: true,
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "docker action",
|
||||
fixture: "docker-action.yml",
|
||||
want: false,
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: testutil.TestCaseNameJavaScriptAction,
|
||||
fixture: "javascript-action.yml",
|
||||
want: false,
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: testutil.TestCaseNameInvalidYAML,
|
||||
fixture: "invalid.yml",
|
||||
want: false,
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Read fixture content using safe helper
|
||||
yamlContent := testutil.MustReadAnalyzerFixture(tt.fixture)
|
||||
|
||||
// Create temp file with action YAML
|
||||
tmpDir, cleanup := testutil.TempDir(t)
|
||||
defer cleanup()
|
||||
|
||||
actionPath := filepath.Join(tmpDir, appconstants.ActionFileNameYML)
|
||||
testutil.WriteTestFile(t, actionPath, yamlContent)
|
||||
|
||||
got, err := IsCompositeAction(actionPath)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("IsCompositeAction() error = %v, wantErr %v", err, tt.wantErr)
|
||||
|
||||
return
|
||||
}
|
||||
if got != tt.want {
|
||||
t.Errorf("IsCompositeAction() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,16 +3,38 @@ package dependencies
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/goccy/go-yaml"
|
||||
|
||||
"github.com/ivuorinen/gh-action-readme/appconstants"
|
||||
)
|
||||
|
||||
// validateFilePath ensures a file path is safe to read.
|
||||
// Returns an error if the path contains traversal attempts.
|
||||
func validateFilePath(path string) error {
|
||||
cleanPath := filepath.Clean(path)
|
||||
|
||||
// Check for ".." components in cleaned path
|
||||
for _, component := range strings.Split(filepath.ToSlash(cleanPath), "/") {
|
||||
if component == ".." {
|
||||
return fmt.Errorf("invalid file path: traversal detected in %q", path)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// parseCompositeActionFromFile reads and parses a composite action file.
|
||||
func (a *Analyzer) parseCompositeActionFromFile(actionPath string) (*ActionWithComposite, error) {
|
||||
// Validate path before reading
|
||||
if err := validateFilePath(actionPath); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Read the file
|
||||
data, err := os.ReadFile(actionPath) // #nosec G304 -- action path from function parameter
|
||||
data, err := os.ReadFile(actionPath) // #nosec G304 -- path validated above
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read action file %s: %w", actionPath, err)
|
||||
}
|
||||
|
||||
62
internal/dependencies/parser_test.go
Normal file
62
internal/dependencies/parser_test.go
Normal file
@@ -0,0 +1,62 @@
|
||||
package dependencies
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestValidateFilePath(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
path string
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "valid relative path",
|
||||
path: "testdata/action.yml",
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "valid absolute path",
|
||||
path: "/tmp/action.yml",
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "traversal with double dots",
|
||||
path: "../../../etc/passwd",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "traversal in middle of path",
|
||||
path: "foo/../../../etc/passwd",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "clean path with dot slash",
|
||||
path: "./foo/bar",
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "valid nested path",
|
||||
path: "internal/testdata/fixtures/action.yml",
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "path with trailing slash",
|
||||
path: "testdata/action.yml/",
|
||||
wantErr: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
err := validateFilePath(tt.path)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("validateFilePath() error = %v, wantErr %v", err, tt.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
749
internal/dependencies/updater_test.go
Normal file
749
internal/dependencies/updater_test.go
Normal file
@@ -0,0 +1,749 @@
|
||||
package dependencies
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/ivuorinen/gh-action-readme/appconstants"
|
||||
"github.com/ivuorinen/gh-action-readme/internal/cache"
|
||||
"github.com/ivuorinen/gh-action-readme/testutil"
|
||||
)
|
||||
|
||||
// newTestAnalyzer creates an Analyzer with cache for testing.
|
||||
// Returns the analyzer and a cleanup function.
|
||||
// Pattern used 7+ times in updater_test.go.
|
||||
func newTestAnalyzer(t *testing.T) (*Analyzer, func()) {
|
||||
t.Helper()
|
||||
|
||||
cacheInstance, err := cache.NewCache(cache.DefaultConfig())
|
||||
testutil.AssertNoError(t, err)
|
||||
|
||||
analyzer := &Analyzer{
|
||||
Cache: NewCacheAdapter(cacheInstance),
|
||||
}
|
||||
|
||||
return analyzer, testutil.CleanupCache(t, cacheInstance)
|
||||
}
|
||||
|
||||
// validatePinnedUpdateSuccess validates that the update succeeded and backup was cleaned up.
|
||||
func validatePinnedUpdateSuccess(t *testing.T, actionPath string, validateBackup bool, analyzer *Analyzer) {
|
||||
t.Helper()
|
||||
|
||||
if validateBackup {
|
||||
testutil.AssertBackupNotExists(t, actionPath)
|
||||
}
|
||||
|
||||
// Verify file is still valid YAML
|
||||
err := analyzer.validateActionFile(actionPath)
|
||||
testutil.AssertNoError(t, err)
|
||||
}
|
||||
|
||||
// validatePinnedUpdateRollback validates that the rollback succeeded and file is unchanged.
|
||||
func validatePinnedUpdateRollback(t *testing.T, actionPath, originalContent string) {
|
||||
t.Helper()
|
||||
|
||||
testutil.ValidateRollback(t, actionPath, originalContent)
|
||||
|
||||
// Backup should be removed after rollback
|
||||
testutil.AssertBackupNotExists(t, actionPath)
|
||||
}
|
||||
|
||||
// TestApplyPinnedUpdates tests the ApplyPinnedUpdates method.
|
||||
// Note: These tests identify a bug where the `- ` list marker is not preserved
|
||||
// when updating YAML. The current implementation replaces entire lines with
|
||||
// just "uses: " prefix, losing the list marker. Tests are written to document
|
||||
// current behavior while validating the logic works.
|
||||
func TestApplyPinnedUpdates(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
actionContent string
|
||||
updates []PinnedUpdate
|
||||
wantErr bool
|
||||
validateBackup bool
|
||||
checkRollback bool
|
||||
}{
|
||||
createSingleUpdateTestCase(singleUpdateParams{
|
||||
name: "list format updates now work correctly (bug fixed)",
|
||||
fixturePath: "dependencies/simple-list-step.yml",
|
||||
oldUses: testutil.TestCheckoutV4OldUses,
|
||||
newUses: testutil.TestCheckoutPinnedV417,
|
||||
commitSHA: testutil.TestActionCheckoutSHA,
|
||||
version: testutil.TestVersionV417,
|
||||
updateType: "patch",
|
||||
wantErr: false,
|
||||
validateBackup: true,
|
||||
checkRollback: false,
|
||||
}),
|
||||
createSingleUpdateTestCase(singleUpdateParams{
|
||||
name: "updates work when uses is not in list format",
|
||||
fixturePath: "dependencies/named-step.yml",
|
||||
oldUses: testutil.TestCheckoutV4OldUses,
|
||||
newUses: testutil.TestCheckoutPinnedV417,
|
||||
commitSHA: testutil.TestActionCheckoutSHA,
|
||||
version: testutil.TestVersionV417,
|
||||
updateType: "patch",
|
||||
wantErr: false,
|
||||
validateBackup: true,
|
||||
checkRollback: false,
|
||||
}),
|
||||
{
|
||||
name: "multiple updates in non-list format",
|
||||
actionContent: testutil.MustReadFixture("dependencies/multiple-steps.yml"),
|
||||
updates: []PinnedUpdate{
|
||||
{
|
||||
FilePath: "", // Will be set by test
|
||||
OldUses: testutil.TestCheckoutV4OldUses,
|
||||
NewUses: testutil.TestCheckoutPinnedV417,
|
||||
CommitSHA: testutil.TestActionCheckoutSHA,
|
||||
Version: testutil.TestVersionV417,
|
||||
UpdateType: "patch",
|
||||
LineNumber: 0,
|
||||
},
|
||||
{
|
||||
FilePath: "", // Will be set by test
|
||||
OldUses: testutil.TestActionSetupNodeV3,
|
||||
NewUses: "actions/setup-node@1a4e6d7c9f8e5b2a3c4d5e6f7a8b9c0d1e2f3a4b # v4.0.0",
|
||||
CommitSHA: "1a4e6d7c9f8e5b2a3c4d5e6f7a8b9c0d1e2f3a4b",
|
||||
Version: "v4.0.0",
|
||||
UpdateType: "major",
|
||||
LineNumber: 0,
|
||||
},
|
||||
},
|
||||
wantErr: false,
|
||||
validateBackup: true,
|
||||
checkRollback: false,
|
||||
},
|
||||
createSingleUpdateTestCase(singleUpdateParams{
|
||||
name: "preserves indentation in non-list format",
|
||||
fixturePath: "dependencies/step-with-parameters.yml",
|
||||
oldUses: testutil.TestCheckoutV4OldUses,
|
||||
newUses: testutil.TestCheckoutPinnedV417,
|
||||
commitSHA: testutil.TestActionCheckoutSHA,
|
||||
version: testutil.TestVersionV417,
|
||||
updateType: "patch",
|
||||
wantErr: false,
|
||||
validateBackup: true,
|
||||
checkRollback: false,
|
||||
}),
|
||||
createSingleUpdateTestCase(singleUpdateParams{
|
||||
name: "handles already pinned dependencies",
|
||||
fixturePath: "dependencies/already-pinned.yml",
|
||||
oldUses: testutil.TestCheckoutPinnedV417,
|
||||
newUses: testutil.TestCheckoutPinnedV417,
|
||||
commitSHA: testutil.TestActionCheckoutSHA,
|
||||
version: testutil.TestVersionV417,
|
||||
updateType: "none",
|
||||
wantErr: false,
|
||||
validateBackup: true,
|
||||
checkRollback: false,
|
||||
}),
|
||||
{
|
||||
name: "invalid YAML triggers rollback",
|
||||
actionContent: testutil.MustReadFixture("dependencies/simple-test-step.yml"),
|
||||
updates: []PinnedUpdate{
|
||||
{
|
||||
FilePath: "", // Will be set by test
|
||||
OldUses: "name: Test Action",
|
||||
NewUses: "invalid:::yaml",
|
||||
CommitSHA: "",
|
||||
Version: "",
|
||||
UpdateType: "none",
|
||||
LineNumber: 0,
|
||||
},
|
||||
},
|
||||
wantErr: true,
|
||||
validateBackup: false,
|
||||
checkRollback: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Create temporary directory and action file
|
||||
dir, cleanup := testutil.TempDir(t)
|
||||
defer cleanup()
|
||||
|
||||
actionPath := testutil.WriteActionFile(t, dir, tt.actionContent)
|
||||
|
||||
// Store original content for rollback check
|
||||
originalContent, _ := os.ReadFile(actionPath) // #nosec G304 -- test file path
|
||||
|
||||
// Set file path in updates
|
||||
for i := range tt.updates {
|
||||
tt.updates[i].FilePath = actionPath
|
||||
}
|
||||
|
||||
// Create analyzer
|
||||
analyzer, cleanupAnalyzer := newTestAnalyzer(t)
|
||||
defer cleanupAnalyzer()
|
||||
|
||||
// Apply updates
|
||||
err := analyzer.ApplyPinnedUpdates(tt.updates)
|
||||
|
||||
// Check error expectation
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("ApplyPinnedUpdates() error = %v, wantErr %v", err, tt.wantErr)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if !tt.wantErr {
|
||||
validatePinnedUpdateSuccess(t, actionPath, tt.validateBackup, analyzer)
|
||||
}
|
||||
|
||||
if tt.checkRollback {
|
||||
validatePinnedUpdateRollback(t, actionPath, string(originalContent))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// validateUpdateFileSuccess validates that the file was updated correctly and backup was cleaned up.
|
||||
func validateUpdateFileSuccess(t *testing.T, actionPath, expectedYAML string, checkBackup bool) {
|
||||
t.Helper()
|
||||
|
||||
testutil.AssertFileContentEquals(t, actionPath, expectedYAML)
|
||||
|
||||
if checkBackup {
|
||||
testutil.AssertBackupNotExists(t, actionPath)
|
||||
}
|
||||
}
|
||||
|
||||
// validateUpdateFileRollback validates that the rollback succeeded and file is unchanged.
|
||||
func validateUpdateFileRollback(t *testing.T, actionPath, initialYAML string) {
|
||||
t.Helper()
|
||||
|
||||
testutil.AssertFileContentEquals(t, actionPath, initialYAML)
|
||||
}
|
||||
|
||||
// TestUpdateActionFile tests the updateActionFile method directly.
|
||||
func TestUpdateActionFile(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
initialYAML string
|
||||
updates []PinnedUpdate
|
||||
expectedYAML string
|
||||
expectError bool
|
||||
checkBackup bool
|
||||
rollbackCheck bool
|
||||
}{
|
||||
{
|
||||
name: "finds and replaces uses statement in non-list format",
|
||||
initialYAML: testutil.MustReadFixture("dependencies/test-checkout-v4.yml"),
|
||||
updates: []PinnedUpdate{
|
||||
{
|
||||
OldUses: testutil.TestCheckoutV4OldUses,
|
||||
NewUses: testutil.TestCheckoutPinnedV411,
|
||||
},
|
||||
},
|
||||
expectedYAML: testutil.MustReadFixture("dependencies/test-checkout-pinned.yml"),
|
||||
expectError: false,
|
||||
checkBackup: true,
|
||||
},
|
||||
{
|
||||
name: "handles different version formats",
|
||||
initialYAML: testutil.MustReadFixture("dependencies/test-checkout-v4-1-0.yml"),
|
||||
updates: []PinnedUpdate{
|
||||
{
|
||||
OldUses: "actions/checkout@v4.1.0",
|
||||
NewUses: testutil.TestCheckoutPinnedV411,
|
||||
},
|
||||
},
|
||||
expectedYAML: testutil.MustReadFixture("dependencies/test-checkout-pinned.yml"),
|
||||
expectError: false,
|
||||
checkBackup: true,
|
||||
},
|
||||
{
|
||||
name: "handles multiple references to same action",
|
||||
initialYAML: testutil.MustReadFixture("dependencies/test-multiple-checkout.yml"),
|
||||
updates: []PinnedUpdate{
|
||||
{
|
||||
OldUses: testutil.TestCheckoutV4OldUses,
|
||||
NewUses: testutil.TestCheckoutPinnedV411,
|
||||
},
|
||||
},
|
||||
expectedYAML: testutil.MustReadFixture("dependencies/test-multiple-checkout-pinned.yml"),
|
||||
expectError: false,
|
||||
checkBackup: true,
|
||||
},
|
||||
{
|
||||
name: "preserves whitespace and comments",
|
||||
initialYAML: testutil.MustReadFixture("dependencies/test-checkout-with-comment.yml"),
|
||||
updates: []PinnedUpdate{
|
||||
{
|
||||
OldUses: testutil.TestCheckoutV4OldUses,
|
||||
NewUses: testutil.TestCheckoutPinnedV411,
|
||||
},
|
||||
},
|
||||
expectedYAML: testutil.MustReadFixture("dependencies/test-checkout-with-comment-pinned.yml"),
|
||||
expectError: false,
|
||||
checkBackup: true,
|
||||
},
|
||||
{
|
||||
name: "invalid YAML triggers rollback",
|
||||
initialYAML: testutil.MustReadFixture(testutil.TestFixtureSimpleCheckout),
|
||||
updates: []PinnedUpdate{
|
||||
{
|
||||
OldUses: testutil.TestCheckoutV4OldUses,
|
||||
NewUses: "\"unclosed string that breaks YAML parsing", // Unclosed quote breaks YAML
|
||||
},
|
||||
},
|
||||
expectedYAML: "", // Should rollback to original
|
||||
expectError: true,
|
||||
checkBackup: false,
|
||||
rollbackCheck: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Create temp directory and file
|
||||
dir, cleanup := testutil.TempDir(t)
|
||||
defer cleanup()
|
||||
|
||||
actionPath := testutil.WriteActionFile(t, dir, tt.initialYAML)
|
||||
|
||||
// Create analyzer
|
||||
analyzer, cleanupAnalyzer := newTestAnalyzer(t)
|
||||
defer cleanupAnalyzer()
|
||||
|
||||
// Apply update
|
||||
err := analyzer.updateActionFile(actionPath, tt.updates)
|
||||
|
||||
// Check error expectation
|
||||
if (err != nil) != tt.expectError {
|
||||
t.Errorf("updateActionFile() error = %v, expectError %v", err, tt.expectError)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if !tt.expectError {
|
||||
validateUpdateFileSuccess(t, actionPath, tt.expectedYAML, tt.checkBackup)
|
||||
}
|
||||
|
||||
if tt.rollbackCheck {
|
||||
validateUpdateFileRollback(t, actionPath, tt.initialYAML)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateActionFile tests the validateActionFile method.
|
||||
func TestValidateActionFile(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
yamlContent string
|
||||
expectValid bool
|
||||
}{
|
||||
{
|
||||
name: "valid composite action",
|
||||
yamlContent: testutil.MustReadFixture("dependencies/simple-list-step.yml"),
|
||||
expectValid: true,
|
||||
},
|
||||
{
|
||||
name: "valid JavaScript action",
|
||||
yamlContent: testutil.MustReadFixture("dependencies/valid-javascript-action.yml"),
|
||||
expectValid: true,
|
||||
},
|
||||
{
|
||||
name: "valid Docker action",
|
||||
yamlContent: testutil.MustReadFixture("dependencies/valid-docker-action.yml"),
|
||||
expectValid: true,
|
||||
},
|
||||
{
|
||||
name: "missing name field",
|
||||
yamlContent: testutil.MustReadFixture("dependencies/missing-name.yml"),
|
||||
expectValid: false,
|
||||
},
|
||||
{
|
||||
name: "missing description field",
|
||||
yamlContent: testutil.MustReadFixture("dependencies/missing-description.yml"),
|
||||
expectValid: false,
|
||||
},
|
||||
{
|
||||
name: "missing runs field",
|
||||
yamlContent: testutil.MustReadFixture("dependencies/missing-runs.yml"),
|
||||
expectValid: false,
|
||||
},
|
||||
{
|
||||
name: "invalid YAML syntax",
|
||||
yamlContent: testutil.MustReadFixture("dependencies/invalid-syntax.yml"),
|
||||
expectValid: false,
|
||||
},
|
||||
{
|
||||
name: "invalid using field",
|
||||
yamlContent: testutil.MustReadFixture("dependencies/invalid-using.yml"),
|
||||
expectValid: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Create temp file
|
||||
dir, cleanup := testutil.TempDir(t)
|
||||
defer cleanup()
|
||||
|
||||
actionPath := testutil.WriteActionFile(t, dir, tt.yamlContent)
|
||||
|
||||
// Create analyzer
|
||||
analyzer, cleanupAnalyzer := newTestAnalyzer(t)
|
||||
defer cleanupAnalyzer()
|
||||
|
||||
// Validate
|
||||
err := analyzer.validateActionFile(actionPath)
|
||||
|
||||
if tt.expectValid && err != nil {
|
||||
t.Errorf("validateActionFile() expected valid but got error: %v", err)
|
||||
}
|
||||
|
||||
if !tt.expectValid && err == nil {
|
||||
t.Errorf("validateActionFile() expected invalid but got nil error")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetLatestTagEdgeCases tests edge cases for getLatestTag.
|
||||
func TestGetLatestTagEdgeCases(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
mockSetup func() *Analyzer
|
||||
owner string
|
||||
repo string
|
||||
expectError bool
|
||||
}{
|
||||
{
|
||||
name: "no tags available",
|
||||
mockSetup: func() *Analyzer {
|
||||
mockClient := testutil.MockGitHubClient(map[string]string{
|
||||
"GET https://api.github.com/repos/test/repo/tags": "[]",
|
||||
})
|
||||
cacheInstance, _ := cache.NewCache(cache.DefaultConfig())
|
||||
|
||||
return &Analyzer{
|
||||
GitHubClient: mockClient,
|
||||
Cache: NewCacheAdapter(cacheInstance),
|
||||
}
|
||||
},
|
||||
owner: "test",
|
||||
repo: "repo",
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
name: "GitHub client nil",
|
||||
mockSetup: func() *Analyzer {
|
||||
return &Analyzer{
|
||||
GitHubClient: nil,
|
||||
Cache: nil,
|
||||
}
|
||||
},
|
||||
owner: "test",
|
||||
repo: "repo",
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
name: "malformed tag response",
|
||||
mockSetup: func() *Analyzer {
|
||||
mockClient := testutil.MockGitHubClient(map[string]string{
|
||||
"GET https://api.github.com/repos/test/repo/tags": "invalid json",
|
||||
})
|
||||
cacheInstance, _ := cache.NewCache(cache.DefaultConfig())
|
||||
|
||||
return &Analyzer{
|
||||
GitHubClient: mockClient,
|
||||
Cache: NewCacheAdapter(cacheInstance),
|
||||
}
|
||||
},
|
||||
owner: "test",
|
||||
repo: "repo",
|
||||
expectError: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
analyzer := tt.mockSetup()
|
||||
if analyzer.Cache != nil {
|
||||
// Clean up cache if it exists
|
||||
defer func() {
|
||||
if ca, ok := analyzer.Cache.(*CacheAdapter); ok {
|
||||
_ = ca.cache.Close()
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
_, _, err := analyzer.getLatestVersion(tt.owner, tt.repo)
|
||||
|
||||
if (err != nil) != tt.expectError {
|
||||
t.Errorf("getLatestVersion() error = %v, expectError %v", err, tt.expectError)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// assertCacheVersionNotFound validates that no version was found in the cache.
|
||||
func assertCacheVersionNotFound(t *testing.T, version, sha string, found bool) {
|
||||
t.Helper()
|
||||
|
||||
if found {
|
||||
t.Error("getCachedVersion() should return false")
|
||||
}
|
||||
if version != "" {
|
||||
t.Errorf("version = %q, want empty", version)
|
||||
}
|
||||
if sha != "" {
|
||||
t.Errorf("sha = %q, want empty", sha)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCacheVersionEdgeCases tests edge cases for cacheVersion and getCachedVersion.
|
||||
func TestCacheVersionEdgeCases(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Parametrized tests for getCachedVersion edge cases
|
||||
notFoundCases := []struct {
|
||||
name string
|
||||
setupFn func(*testing.T) (*Analyzer, func())
|
||||
cacheKey string
|
||||
}{
|
||||
{
|
||||
name: "nil cache",
|
||||
setupFn: func(_ *testing.T) (*Analyzer, func()) {
|
||||
return &Analyzer{Cache: nil}, func() {
|
||||
// No cleanup needed for nil cache
|
||||
}
|
||||
},
|
||||
cacheKey: testutil.CacheTestKey,
|
||||
},
|
||||
{
|
||||
name: "invalid data type",
|
||||
setupFn: func(t *testing.T) (*Analyzer, func()) {
|
||||
t.Helper()
|
||||
c, err := cache.NewCache(cache.DefaultConfig())
|
||||
testutil.AssertNoError(t, err)
|
||||
_ = c.Set(testutil.CacheTestKey, "invalid-string")
|
||||
|
||||
return &Analyzer{Cache: NewCacheAdapter(c)}, testutil.CleanupCache(t, c)
|
||||
},
|
||||
cacheKey: testutil.CacheTestKey,
|
||||
},
|
||||
{
|
||||
name: "empty cache entry",
|
||||
setupFn: func(t *testing.T) (*Analyzer, func()) {
|
||||
t.Helper()
|
||||
c, err := cache.NewCache(cache.DefaultConfig())
|
||||
testutil.AssertNoError(t, err)
|
||||
|
||||
return &Analyzer{Cache: NewCacheAdapter(c)}, testutil.CleanupCache(t, c)
|
||||
},
|
||||
cacheKey: "nonexistent-key",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range notFoundCases {
|
||||
t.Run("getCachedVersion with "+tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
analyzer, cleanup := tc.setupFn(t)
|
||||
defer cleanup()
|
||||
version, sha, found := analyzer.getCachedVersion(tc.cacheKey)
|
||||
assertCacheVersionNotFound(t, version, sha, found)
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("cacheVersion with nil cache", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
analyzer := &Analyzer{Cache: nil}
|
||||
// Should not panic
|
||||
analyzer.cacheVersion(testutil.CacheTestKey, "v1.0.0", "abc123")
|
||||
})
|
||||
|
||||
t.Run("cacheVersion stores and retrieves correctly", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cacheInstance, err := cache.NewCache(cache.DefaultConfig())
|
||||
testutil.AssertNoError(t, err)
|
||||
defer testutil.CleanupCache(t, cacheInstance)()
|
||||
|
||||
analyzer := &Analyzer{Cache: NewCacheAdapter(cacheInstance)}
|
||||
|
||||
// Cache a version
|
||||
analyzer.cacheVersion(testutil.CacheTestKey, "v1.2.3", "def456")
|
||||
|
||||
// Retrieve it
|
||||
version, sha, found := analyzer.getCachedVersion(testutil.CacheTestKey)
|
||||
|
||||
if !found {
|
||||
t.Error("getCachedVersion() should return true after cacheVersion()")
|
||||
}
|
||||
if version != "v1.2.3" {
|
||||
t.Errorf("getCachedVersion() version = %s, want v1.2.3", version)
|
||||
}
|
||||
if sha != "def456" {
|
||||
t.Errorf("getCachedVersion() sha = %s, want def456", sha)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestUpdateActionFileBackupAndRollback tests backup creation and rollback functionality.
|
||||
func TestUpdateActionFileBackupAndRollback(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("backup created before modification", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dir, cleanup := testutil.TempDir(t)
|
||||
defer cleanup()
|
||||
|
||||
originalContent := testutil.MustReadFixture(testutil.TestFixtureSimpleCheckout)
|
||||
actionPath := testutil.WriteActionFile(t, dir, originalContent)
|
||||
|
||||
analyzer, cleanupAnalyzer := newTestAnalyzer(t)
|
||||
defer cleanupAnalyzer()
|
||||
|
||||
updates := []PinnedUpdate{
|
||||
{
|
||||
OldUses: testutil.TestCheckoutV4OldUses,
|
||||
NewUses: testutil.TestCheckoutPinnedV411,
|
||||
},
|
||||
}
|
||||
|
||||
err := analyzer.updateActionFile(actionPath, updates)
|
||||
testutil.AssertNoError(t, err)
|
||||
|
||||
// Backup should be removed after successful update
|
||||
testutil.AssertBackupNotExists(t, actionPath)
|
||||
})
|
||||
|
||||
t.Run("rollback on validation failure", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dir, cleanup := testutil.TempDir(t)
|
||||
defer cleanup()
|
||||
|
||||
originalContent := testutil.MustReadFixture(testutil.TestFixtureSimpleCheckout)
|
||||
actionPath := testutil.WriteActionFile(t, dir, originalContent)
|
||||
|
||||
analyzer, cleanupAnalyzer := newTestAnalyzer(t)
|
||||
defer cleanupAnalyzer()
|
||||
|
||||
// Create an update that breaks YAML
|
||||
updates := []PinnedUpdate{
|
||||
{
|
||||
OldUses: "name: Test",
|
||||
NewUses: "invalid::yaml::syntax:",
|
||||
},
|
||||
}
|
||||
|
||||
err := analyzer.updateActionFile(actionPath, updates)
|
||||
if err == nil {
|
||||
t.Error("updateActionFile() should return error for invalid YAML")
|
||||
}
|
||||
|
||||
// File should be rolled back to original
|
||||
testutil.AssertFileContentEquals(t, actionPath, originalContent)
|
||||
|
||||
// Backup should be removed after rollback
|
||||
testutil.AssertBackupNotExists(t, actionPath)
|
||||
})
|
||||
|
||||
t.Run("file permission errors", func(t *testing.T) {
|
||||
// Skip on Windows as permission handling is different
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("Skipping permission test on Windows")
|
||||
}
|
||||
|
||||
dir, cleanup := testutil.TempDir(t)
|
||||
defer cleanup()
|
||||
|
||||
actionPath := filepath.Join(dir, appconstants.ActionFileNameYML)
|
||||
testutil.WriteTestFile(t, actionPath, "name: Test\ndescription: Test\nruns:\n using: composite\n steps: []")
|
||||
|
||||
// Make file read-only
|
||||
err := os.Chmod(actionPath, 0444) // #nosec G302 -- intentionally read-only for test
|
||||
testutil.AssertNoError(t, err)
|
||||
|
||||
analyzer, cleanupAnalyzer := newTestAnalyzer(t)
|
||||
defer cleanupAnalyzer()
|
||||
|
||||
updates := []PinnedUpdate{
|
||||
{
|
||||
OldUses: "anything",
|
||||
NewUses: "something",
|
||||
},
|
||||
}
|
||||
|
||||
err = analyzer.updateActionFile(actionPath, updates)
|
||||
if err == nil {
|
||||
t.Error("updateActionFile() should return error for read-only file")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestApplyPinnedUpdatesGroupedByFile tests updates to multiple files.
|
||||
func TestApplyPinnedUpdatesGroupedByFile(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dir, cleanup := testutil.TempDir(t)
|
||||
defer cleanup()
|
||||
|
||||
// Create two action files in non-list format (to avoid YAML bug)
|
||||
action1Path := filepath.Join(dir, "action1.yml")
|
||||
action2Path := filepath.Join(dir, "action2.yml")
|
||||
|
||||
action1Content := testutil.MustReadFixture("dependencies/action1-checkout.yml")
|
||||
action2Content := testutil.MustReadFixture("dependencies/action2-setup-node.yml")
|
||||
|
||||
testutil.WriteTestFile(t, action1Path, action1Content)
|
||||
testutil.WriteTestFile(t, action2Path, action2Content)
|
||||
|
||||
analyzer, cleanupAnalyzer := newTestAnalyzer(t)
|
||||
defer cleanupAnalyzer()
|
||||
|
||||
// Create updates for both files
|
||||
updates := []PinnedUpdate{
|
||||
{
|
||||
FilePath: action1Path,
|
||||
OldUses: testutil.TestCheckoutV4OldUses,
|
||||
NewUses: testutil.TestCheckoutPinnedV411,
|
||||
},
|
||||
{
|
||||
FilePath: action2Path,
|
||||
OldUses: testutil.TestActionSetupNodeV3,
|
||||
NewUses: "actions/setup-node@def456 # v4.0.0",
|
||||
},
|
||||
}
|
||||
|
||||
err := analyzer.ApplyPinnedUpdates(updates)
|
||||
testutil.AssertNoError(t, err)
|
||||
|
||||
// Verify both files were updated
|
||||
content1 := testutil.SafeReadFile(t, action1Path, dir)
|
||||
if !strings.Contains(string(content1), testutil.TestCheckoutPinnedV411) {
|
||||
t.Errorf("action1.yml was not updated correctly, got:\n%s", string(content1))
|
||||
}
|
||||
|
||||
content2 := testutil.SafeReadFile(t, action2Path, dir)
|
||||
if !strings.Contains(string(content2), "actions/setup-node@def456 # v4.0.0") {
|
||||
t.Errorf("action2.yml was not updated correctly, got:\n%s", string(content2))
|
||||
}
|
||||
}
|
||||
48
internal/dependencies/updater_test_helper.go
Normal file
48
internal/dependencies/updater_test_helper.go
Normal file
@@ -0,0 +1,48 @@
|
||||
package dependencies
|
||||
|
||||
import "github.com/ivuorinen/gh-action-readme/testutil"
|
||||
|
||||
// singleUpdateParams holds parameters for creating a test case with a single update.
|
||||
type singleUpdateParams struct {
|
||||
name string
|
||||
fixturePath string
|
||||
oldUses, newUses, commitSHA, version, updateType string
|
||||
wantErr, validateBackup, checkRollback bool
|
||||
}
|
||||
|
||||
// createSingleUpdateTestCase creates a test case with a single PinnedUpdate.
|
||||
// This helper reduces duplication for test cases that update a single dependency.
|
||||
func createSingleUpdateTestCase(params singleUpdateParams) struct {
|
||||
name string
|
||||
actionContent string
|
||||
updates []PinnedUpdate
|
||||
wantErr bool
|
||||
validateBackup bool
|
||||
checkRollback bool
|
||||
} {
|
||||
return struct {
|
||||
name string
|
||||
actionContent string
|
||||
updates []PinnedUpdate
|
||||
wantErr bool
|
||||
validateBackup bool
|
||||
checkRollback bool
|
||||
}{
|
||||
name: params.name,
|
||||
actionContent: testutil.MustReadFixture(params.fixturePath),
|
||||
updates: []PinnedUpdate{
|
||||
{
|
||||
FilePath: "", // Will be set by test
|
||||
OldUses: params.oldUses,
|
||||
NewUses: params.newUses,
|
||||
CommitSHA: params.commitSHA,
|
||||
Version: params.version,
|
||||
UpdateType: params.updateType,
|
||||
LineNumber: 0,
|
||||
},
|
||||
},
|
||||
wantErr: params.wantErr,
|
||||
validateBackup: params.validateBackup,
|
||||
checkRollback: params.checkRollback,
|
||||
}
|
||||
}
|
||||
361
internal/errorhandler_integration_test.go
Normal file
361
internal/errorhandler_integration_test.go
Normal file
@@ -0,0 +1,361 @@
|
||||
package internal_test
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/ivuorinen/gh-action-readme/appconstants"
|
||||
"github.com/ivuorinen/gh-action-readme/internal"
|
||||
"github.com/ivuorinen/gh-action-readme/internal/apperrors"
|
||||
"github.com/ivuorinen/gh-action-readme/testutil"
|
||||
)
|
||||
|
||||
const (
|
||||
envGoTestSubprocess = "GO_TEST_SUBPROCESS"
|
||||
envTestType = "TEST_TYPE"
|
||||
)
|
||||
|
||||
// verifyExitCode checks that the command exited with the expected exit code.
|
||||
func verifyExitCode(t *testing.T, err error, expectedExit int) {
|
||||
t.Helper()
|
||||
if exitErr, ok := err.(*exec.ExitError); ok {
|
||||
if exitErr.ExitCode() != expectedExit {
|
||||
t.Errorf("expected exit code %d, got %d", expectedExit, exitErr.ExitCode())
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf(testutil.TestErrUnexpected, err)
|
||||
}
|
||||
if expectedExit != 0 {
|
||||
t.Errorf("expected exit code %d, but process exited successfully", expectedExit)
|
||||
}
|
||||
}
|
||||
|
||||
// execSubprocessTest spawns a subprocess and returns its stderr output and error.
|
||||
func execSubprocessTest(t *testing.T, testType string) (string, error) {
|
||||
t.Helper()
|
||||
//nolint:gosec // Controlled test arguments
|
||||
cmd := exec.Command(os.Args[0], "-test.run=^TestErrorHandlerIntegration$")
|
||||
cmd.Env = append(os.Environ(),
|
||||
envGoTestSubprocess+"=1",
|
||||
envTestType+"="+testType,
|
||||
)
|
||||
|
||||
stderr, err := cmd.StderrPipe()
|
||||
if err != nil {
|
||||
t.Fatalf("failed to get stderr pipe: %v", err)
|
||||
}
|
||||
|
||||
if err := cmd.Start(); err != nil {
|
||||
t.Fatalf("failed to start subprocess: %v", err)
|
||||
}
|
||||
|
||||
stderrOutput := make([]byte, 4096)
|
||||
n, _ := stderr.Read(stderrOutput)
|
||||
stderrStr := string(stderrOutput[:n])
|
||||
|
||||
return stderrStr, cmd.Wait()
|
||||
}
|
||||
|
||||
// runSubprocessErrorTest executes a subprocess test and verifies exit code and stderr.
|
||||
// Consolidates 15 duplicated test loops.
|
||||
func runSubprocessErrorTest(t *testing.T, testType string, expectedExit int, expectedStderr string) {
|
||||
t.Helper()
|
||||
|
||||
stderrStr, err := execSubprocessTest(t, testType)
|
||||
verifyExitCode(t, err, expectedExit)
|
||||
|
||||
if !strings.Contains(strings.ToLower(stderrStr), strings.ToLower(expectedStderr)) {
|
||||
t.Errorf("stderr missing expected text %q, got: %s", expectedStderr, stderrStr)
|
||||
}
|
||||
}
|
||||
|
||||
// TestErrorHandlerIntegration tests error handler methods that call os.Exit()
|
||||
// using subprocess pattern.
|
||||
func TestErrorHandlerIntegration(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Check if this is the subprocess
|
||||
if os.Getenv(envGoTestSubprocess) == "1" {
|
||||
runSubprocessTest()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
testType string
|
||||
expectedExit int
|
||||
expectedStderr string
|
||||
}{
|
||||
{
|
||||
name: "HandleError with file not found",
|
||||
testType: "handle_error_file_not_found",
|
||||
expectedExit: appconstants.ExitCodeError,
|
||||
expectedStderr: testutil.TestErrFileNotFound,
|
||||
},
|
||||
{
|
||||
name: "HandleError with validation error",
|
||||
testType: "handle_error_validation",
|
||||
expectedExit: appconstants.ExitCodeError,
|
||||
expectedStderr: "validation failed",
|
||||
},
|
||||
{
|
||||
name: "HandleError with context",
|
||||
testType: "handle_error_with_context",
|
||||
expectedExit: appconstants.ExitCodeError,
|
||||
expectedStderr: "config file",
|
||||
},
|
||||
{
|
||||
name: "HandleError with suggestions",
|
||||
testType: "handle_error_with_suggestions",
|
||||
expectedExit: appconstants.ExitCodeError,
|
||||
expectedStderr: testutil.TestErrFileError,
|
||||
},
|
||||
{
|
||||
name: "HandleFatalError with permission denied",
|
||||
testType: "handle_fatal_error_permission",
|
||||
expectedExit: appconstants.ExitCodeError,
|
||||
expectedStderr: testutil.TestErrPermissionDenied,
|
||||
},
|
||||
{
|
||||
name: "HandleFatalError with config error",
|
||||
testType: "handle_fatal_error_config",
|
||||
expectedExit: appconstants.ExitCodeError,
|
||||
expectedStderr: "configuration error",
|
||||
},
|
||||
{
|
||||
name: "HandleSimpleError with generic error",
|
||||
testType: "handle_simple_error_generic",
|
||||
expectedExit: appconstants.ExitCodeError,
|
||||
expectedStderr: "operation failed",
|
||||
},
|
||||
{
|
||||
name: "HandleSimpleError with file not found pattern",
|
||||
testType: "handle_simple_error_not_found",
|
||||
expectedExit: appconstants.ExitCodeError,
|
||||
expectedStderr: testutil.TestErrFileError,
|
||||
},
|
||||
{
|
||||
name: "HandleSimpleError with permission pattern",
|
||||
testType: "handle_simple_error_permission",
|
||||
expectedExit: appconstants.ExitCodeError,
|
||||
expectedStderr: "access error",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
runSubprocessErrorTest(t, tt.testType, tt.expectedExit, tt.expectedStderr)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// runSubprocessTest executes the actual error handler call based on TEST_TYPE.
|
||||
func runSubprocessTest() {
|
||||
testType := os.Getenv(envTestType)
|
||||
output := internal.NewColoredOutput(false) // quiet=false
|
||||
handler := internal.NewErrorHandler(output)
|
||||
|
||||
switch testType {
|
||||
case "handle_error_file_not_found":
|
||||
err := apperrors.New(appconstants.ErrCodeFileNotFound, testutil.TestErrFileNotFound)
|
||||
handler.HandleError(err)
|
||||
|
||||
case "handle_error_validation":
|
||||
err := apperrors.New(appconstants.ErrCodeValidation, "validation failed")
|
||||
handler.HandleError(err)
|
||||
|
||||
case "handle_error_with_context":
|
||||
err := apperrors.New(appconstants.ErrCodeConfiguration, "config file missing")
|
||||
err = err.WithDetails(map[string]string{
|
||||
"path": "/invalid/path/config.yaml",
|
||||
"type": "application",
|
||||
})
|
||||
handler.HandleError(err)
|
||||
|
||||
case "handle_error_with_suggestions":
|
||||
err := apperrors.New(appconstants.ErrCodeFileNotFound, "file error occurred")
|
||||
err = err.WithSuggestions("Check that the file exists", "Verify file permissions")
|
||||
handler.HandleError(err)
|
||||
|
||||
case "handle_fatal_error_permission":
|
||||
handler.HandleFatalError(
|
||||
appconstants.ErrCodePermission,
|
||||
"permission denied accessing file",
|
||||
map[string]string{"file": "/etc/passwd"},
|
||||
)
|
||||
|
||||
case "handle_fatal_error_config":
|
||||
handler.HandleFatalError(
|
||||
appconstants.ErrCodeConfiguration,
|
||||
"configuration error in settings",
|
||||
map[string]string{
|
||||
"section": "github",
|
||||
"key": "token",
|
||||
},
|
||||
)
|
||||
|
||||
case "handle_simple_error_generic":
|
||||
handler.HandleSimpleError("operation failed", errors.New("generic error occurred"))
|
||||
|
||||
case "handle_simple_error_not_found":
|
||||
handler.HandleSimpleError(testutil.TestErrFileError, errors.New("no such file or directory"))
|
||||
|
||||
case "handle_simple_error_permission":
|
||||
handler.HandleSimpleError("access error", errors.New(testutil.TestErrPermissionDenied))
|
||||
|
||||
default:
|
||||
os.Exit(99) // Unexpected test type
|
||||
}
|
||||
}
|
||||
|
||||
// TestErrorHandlerAllErrorCodes tests that all error codes produce correct exit codes.
|
||||
func TestErrorHandlerAllErrorCodes(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Check if this is the subprocess
|
||||
if os.Getenv(envGoTestSubprocess) == "1" {
|
||||
runErrorCodeTest()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
errorCodes := []struct {
|
||||
code appconstants.ErrorCode
|
||||
description string
|
||||
}{
|
||||
{appconstants.ErrCodeFileNotFound, testutil.TestErrFileNotFound},
|
||||
{appconstants.ErrCodePermission, testutil.TestErrPermissionDenied},
|
||||
{appconstants.ErrCodeInvalidYAML, testutil.TestCaseNameInvalidYAML},
|
||||
{appconstants.ErrCodeInvalidAction, "invalid action"},
|
||||
{appconstants.ErrCodeNoActionFiles, testutil.TestCaseNameNoActionFiles},
|
||||
{appconstants.ErrCodeGitHubAPI, "github api error"},
|
||||
{appconstants.ErrCodeGitHubRateLimit, "rate limit"},
|
||||
{appconstants.ErrCodeGitHubAuth, "auth error"},
|
||||
{appconstants.ErrCodeConfiguration, "configuration error"},
|
||||
{appconstants.ErrCodeValidation, "validation error"},
|
||||
{appconstants.ErrCodeTemplateRender, "template error"},
|
||||
{appconstants.ErrCodeFileWrite, "file write error"},
|
||||
{appconstants.ErrCodeDependencyAnalysis, "dependency error"},
|
||||
{appconstants.ErrCodeCacheAccess, "cache error"},
|
||||
{appconstants.ErrCodeUnknown, testutil.TestCaseNameUnknownError},
|
||||
}
|
||||
|
||||
for _, tc := range errorCodes {
|
||||
t.Run(string(tc.code), func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
//nolint:gosec // Controlled test arguments
|
||||
cmd := exec.Command(os.Args[0], "-test.run=^TestErrorHandlerAllErrorCodes$/^"+string(tc.code)+"$")
|
||||
cmd.Env = append(os.Environ(),
|
||||
"GO_TEST_SUBPROCESS=1",
|
||||
"ERROR_CODE="+string(tc.code),
|
||||
"ERROR_DESC="+tc.description,
|
||||
)
|
||||
|
||||
stderr, _ := cmd.StderrPipe()
|
||||
_ = cmd.Start()
|
||||
|
||||
stderrOutput := make([]byte, 4096)
|
||||
n, _ := stderr.Read(stderrOutput)
|
||||
stderrStr := string(stderrOutput[:n])
|
||||
|
||||
err := cmd.Wait()
|
||||
|
||||
// All errors should exit with ExitCodeError (1)
|
||||
if exitErr, ok := err.(*exec.ExitError); ok {
|
||||
if exitErr.ExitCode() != appconstants.ExitCodeError {
|
||||
t.Errorf("expected exit code %d, got %d", appconstants.ExitCodeError, exitErr.ExitCode())
|
||||
}
|
||||
} else if err != nil {
|
||||
t.Fatalf(testutil.TestErrUnexpected, err)
|
||||
} else {
|
||||
t.Error("expected non-zero exit code")
|
||||
}
|
||||
|
||||
// Verify error message appears in output
|
||||
if !strings.Contains(strings.ToLower(stderrStr), strings.ToLower(tc.description)) {
|
||||
t.Errorf("stderr missing expected error description %q, got: %s", tc.description, stderrStr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// runErrorCodeTest handles subprocess execution for error code tests.
|
||||
func runErrorCodeTest() {
|
||||
code := appconstants.ErrorCode(os.Getenv("ERROR_CODE"))
|
||||
desc := os.Getenv("ERROR_DESC")
|
||||
|
||||
output := internal.NewColoredOutput(false)
|
||||
handler := internal.NewErrorHandler(output)
|
||||
|
||||
err := apperrors.New(code, desc)
|
||||
handler.HandleError(err)
|
||||
}
|
||||
|
||||
// TestErrorHandlerWithComplexContext tests error handler with multiple context values and suggestions.
|
||||
func TestErrorHandlerWithComplexContext(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
if os.Getenv(envGoTestSubprocess) == "1" {
|
||||
runComplexContextTest()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
//nolint:gosec // Controlled test arguments
|
||||
cmd := exec.Command(os.Args[0], "-test.run=^TestErrorHandlerWithComplexContext$")
|
||||
cmd.Env = append(os.Environ(), "GO_TEST_SUBPROCESS=1")
|
||||
|
||||
stderr, _ := cmd.StderrPipe()
|
||||
_ = cmd.Start()
|
||||
|
||||
stderrOutput := make([]byte, 8192)
|
||||
n, _ := stderr.Read(stderrOutput)
|
||||
stderrStr := string(stderrOutput[:n])
|
||||
|
||||
_ = cmd.Wait()
|
||||
|
||||
// Verify all context keys are displayed
|
||||
contextKeys := []string{"path", "action", "reason"}
|
||||
for _, key := range contextKeys {
|
||||
if !strings.Contains(stderrStr, key) {
|
||||
t.Errorf("stderr missing context key %q", key)
|
||||
}
|
||||
}
|
||||
|
||||
// Verify suggestions are displayed
|
||||
suggestions := []string{"Check the file path", "Verify YAML syntax", "Consult documentation"}
|
||||
for _, suggestion := range suggestions {
|
||||
if !strings.Contains(stderrStr, suggestion) {
|
||||
t.Errorf("stderr missing suggestion %q", suggestion)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// runComplexContextTest handles subprocess execution for complex context test.
|
||||
func runComplexContextTest() {
|
||||
output := internal.NewColoredOutput(false)
|
||||
handler := internal.NewErrorHandler(output)
|
||||
|
||||
err := apperrors.New(appconstants.ErrCodeInvalidYAML, "YAML parsing failed")
|
||||
err = err.WithDetails(map[string]string{
|
||||
"path": "/path/to/action.yml",
|
||||
"action": "parse-workflow",
|
||||
"reason": "invalid syntax at line 42",
|
||||
})
|
||||
err = err.WithSuggestions(
|
||||
"Check the file path is correct",
|
||||
"Verify YAML syntax is valid",
|
||||
"Consult documentation for proper format",
|
||||
)
|
||||
|
||||
handler.HandleError(err)
|
||||
}
|
||||
62
internal/errorhandler_integration_test_helpers.go
Normal file
62
internal/errorhandler_integration_test_helpers.go
Normal file
@@ -0,0 +1,62 @@
|
||||
package internal
|
||||
|
||||
import (
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// spawnTestSubprocess creates and configures a test subprocess.
|
||||
// This helper reduces cognitive complexity in integration tests by centralizing
|
||||
// the subprocess creation logic.
|
||||
//
|
||||
//nolint:unused // Prepared for future use in errorhandler integration tests
|
||||
func spawnTestSubprocess(t *testing.T, testType string) *exec.Cmd {
|
||||
t.Helper()
|
||||
|
||||
//nolint:gosec // G204: Controlled test arguments, not user input
|
||||
cmd := exec.Command(os.Args[0], "-test.run=TestErrorHandlerIntegration")
|
||||
cmd.Env = append(os.Environ(), "GO_TEST_SUBPROCESS=1", "TEST_TYPE="+testType)
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
// assertSubprocessExit validates subprocess exit code and stderr.
|
||||
// This helper reduces cognitive complexity in integration tests by centralizing
|
||||
// the subprocess validation logic that was repeated across test cases.
|
||||
//
|
||||
//nolint:unused // Prepared for future use in errorhandler integration tests
|
||||
func assertSubprocessExit(t *testing.T, cmd *exec.Cmd, expectedExitCode int, stderrPattern string) {
|
||||
t.Helper()
|
||||
|
||||
stderr, err := cmd.StderrPipe()
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create stderr pipe: %v", err)
|
||||
}
|
||||
|
||||
if err := cmd.Start(); err != nil {
|
||||
t.Fatalf("failed to start subprocess: %v", err)
|
||||
}
|
||||
|
||||
stderrBytes, _ := io.ReadAll(stderr)
|
||||
stderrStr := string(stderrBytes)
|
||||
|
||||
err = cmd.Wait()
|
||||
|
||||
// Validate exit code
|
||||
exitCode := 0
|
||||
if exitErr, ok := err.(*exec.ExitError); ok {
|
||||
exitCode = exitErr.ExitCode()
|
||||
}
|
||||
|
||||
if exitCode != expectedExitCode {
|
||||
t.Errorf("exit code = %d, want %d", exitCode, expectedExitCode)
|
||||
}
|
||||
|
||||
// Validate stderr contains pattern
|
||||
if stderrPattern != "" && !strings.Contains(stderrStr, stderrPattern) {
|
||||
t.Errorf("stderr does not contain %q, got: %s", stderrPattern, stderrStr)
|
||||
}
|
||||
}
|
||||
321
internal/errorhandler_test.go
Normal file
321
internal/errorhandler_test.go
Normal file
@@ -0,0 +1,321 @@
|
||||
package internal
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/ivuorinen/gh-action-readme/appconstants"
|
||||
"github.com/ivuorinen/gh-action-readme/internal/apperrors"
|
||||
"github.com/ivuorinen/gh-action-readme/testutil"
|
||||
)
|
||||
|
||||
// newTestErrorHandler creates an ErrorHandler for testing with quiet output.
|
||||
// Reduces duplication across error handler tests.
|
||||
func newTestErrorHandler() *ErrorHandler {
|
||||
return NewErrorHandler(&ColoredOutput{NoColor: true, Quiet: true})
|
||||
}
|
||||
|
||||
// TestNewErrorHandler tests error handler creation.
|
||||
func TestNewErrorHandler(t *testing.T) {
|
||||
output := &ColoredOutput{NoColor: true, Quiet: true}
|
||||
handler := NewErrorHandler(output)
|
||||
|
||||
if handler == nil {
|
||||
t.Fatal("NewErrorHandler() returned nil")
|
||||
}
|
||||
|
||||
if handler.output != output {
|
||||
t.Error("NewErrorHandler() did not set output correctly")
|
||||
}
|
||||
}
|
||||
|
||||
// TestDetermineErrorCode tests error code determination.
|
||||
//
|
||||
|
||||
func TestDetermineErrorCode(t *testing.T) {
|
||||
handler := newTestErrorHandler()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
err error
|
||||
wantCode appconstants.ErrorCode
|
||||
}{
|
||||
{
|
||||
name: "file not found - typed error",
|
||||
err: apperrors.ErrFileNotFound,
|
||||
wantCode: appconstants.ErrCodeFileNotFound,
|
||||
},
|
||||
{
|
||||
name: "file not found - os.ErrNotExist",
|
||||
err: os.ErrNotExist,
|
||||
wantCode: appconstants.ErrCodeFileNotFound,
|
||||
},
|
||||
{
|
||||
name: "permission denied - typed error",
|
||||
err: apperrors.ErrPermissionDenied,
|
||||
wantCode: appconstants.ErrCodePermission,
|
||||
},
|
||||
{
|
||||
name: "permission denied - os.ErrPermission",
|
||||
err: os.ErrPermission,
|
||||
wantCode: appconstants.ErrCodePermission,
|
||||
},
|
||||
{
|
||||
name: "invalid YAML",
|
||||
err: apperrors.ErrInvalidYAML,
|
||||
wantCode: appconstants.ErrCodeInvalidYAML,
|
||||
},
|
||||
{
|
||||
name: "GitHub API error",
|
||||
err: apperrors.ErrGitHubAPI,
|
||||
wantCode: appconstants.ErrCodeGitHubAPI,
|
||||
},
|
||||
{
|
||||
name: "configuration error",
|
||||
err: apperrors.ErrConfiguration,
|
||||
wantCode: appconstants.ErrCodeConfiguration,
|
||||
},
|
||||
{
|
||||
name: testutil.TestCaseNameUnknownError,
|
||||
err: errors.New("some random error"),
|
||||
wantCode: appconstants.ErrCodeUnknown,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := handler.determineErrorCode(tt.err)
|
||||
if got != tt.wantCode {
|
||||
t.Errorf("determineErrorCode() = %v, want %v", got, tt.wantCode)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestCheckTypedError tests typed error checking.
|
||||
//
|
||||
|
||||
func TestCheckTypedError(t *testing.T) {
|
||||
handler := newTestErrorHandler()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
err error
|
||||
wantCode appconstants.ErrorCode
|
||||
}{
|
||||
{
|
||||
name: "ErrFileNotFound",
|
||||
err: apperrors.ErrFileNotFound,
|
||||
wantCode: appconstants.ErrCodeFileNotFound,
|
||||
},
|
||||
{
|
||||
name: "os.ErrNotExist",
|
||||
err: os.ErrNotExist,
|
||||
wantCode: appconstants.ErrCodeFileNotFound,
|
||||
},
|
||||
{
|
||||
name: "ErrPermissionDenied",
|
||||
err: apperrors.ErrPermissionDenied,
|
||||
wantCode: appconstants.ErrCodePermission,
|
||||
},
|
||||
{
|
||||
name: "os.ErrPermission",
|
||||
err: os.ErrPermission,
|
||||
wantCode: appconstants.ErrCodePermission,
|
||||
},
|
||||
{
|
||||
name: "ErrInvalidYAML",
|
||||
err: apperrors.ErrInvalidYAML,
|
||||
wantCode: appconstants.ErrCodeInvalidYAML,
|
||||
},
|
||||
{
|
||||
name: "ErrGitHubAPI",
|
||||
err: apperrors.ErrGitHubAPI,
|
||||
wantCode: appconstants.ErrCodeGitHubAPI,
|
||||
},
|
||||
{
|
||||
name: "ErrConfiguration",
|
||||
err: apperrors.ErrConfiguration,
|
||||
wantCode: appconstants.ErrCodeConfiguration,
|
||||
},
|
||||
{
|
||||
name: testutil.TestCaseNameUnknownError,
|
||||
err: errors.New(testutil.UnknownErrorMsg),
|
||||
wantCode: appconstants.ErrCodeUnknown,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := handler.checkTypedError(tt.err)
|
||||
if got != tt.wantCode {
|
||||
t.Errorf("checkTypedError() = %v, want %v", got, tt.wantCode)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestCheckStringPatterns tests string pattern matching.
|
||||
func TestCheckStringPatterns(t *testing.T) {
|
||||
handler := newTestErrorHandler()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
errStr string
|
||||
wantCode appconstants.ErrorCode
|
||||
}{
|
||||
{
|
||||
name: "file not found pattern",
|
||||
errStr: "no such file or directory",
|
||||
wantCode: appconstants.ErrCodeFileNotFound,
|
||||
},
|
||||
{
|
||||
name: "permission denied pattern",
|
||||
errStr: "permission denied",
|
||||
wantCode: appconstants.ErrCodePermission,
|
||||
},
|
||||
{
|
||||
name: "YAML error pattern",
|
||||
errStr: "yaml: unmarshal error",
|
||||
wantCode: appconstants.ErrCodeInvalidYAML,
|
||||
},
|
||||
{
|
||||
name: "GitHub API pattern",
|
||||
errStr: "GitHub API error",
|
||||
wantCode: appconstants.ErrCodeGitHubAPI,
|
||||
},
|
||||
{
|
||||
name: "configuration pattern",
|
||||
errStr: "configuration error",
|
||||
wantCode: appconstants.ErrCodeConfiguration,
|
||||
},
|
||||
{
|
||||
name: "unknown pattern",
|
||||
errStr: "some random error message",
|
||||
wantCode: appconstants.ErrCodeUnknown,
|
||||
},
|
||||
{
|
||||
name: "case insensitive matching",
|
||||
errStr: "PERMISSION DENIED",
|
||||
wantCode: appconstants.ErrCodePermission,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := handler.checkStringPatterns(tt.errStr)
|
||||
if got != tt.wantCode {
|
||||
t.Errorf("checkStringPatterns(%q) = %v, want %v", tt.errStr, got, tt.wantCode)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestContains tests the contains helper function.
|
||||
func TestContains(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
s string
|
||||
substr string
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
name: "exact match",
|
||||
s: testutil.ValidationHelloWorld,
|
||||
substr: "hello",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "case insensitive match",
|
||||
s: "Hello World",
|
||||
substr: "hello",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: testutil.TestCaseNameNoMatch,
|
||||
s: testutil.ValidationHelloWorld,
|
||||
substr: "goodbye",
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "empty substring",
|
||||
s: testutil.ValidationHelloWorld,
|
||||
substr: "",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "empty string",
|
||||
s: "",
|
||||
substr: "hello",
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "substring in middle",
|
||||
s: "the quick brown fox",
|
||||
substr: "quick",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "case insensitive - uppercase string",
|
||||
s: "ERROR: PERMISSION DENIED",
|
||||
substr: "permission",
|
||||
want: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := contains(tt.s, tt.substr)
|
||||
if got != tt.want {
|
||||
t.Errorf("contains(%q, %q) = %v, want %v", tt.s, tt.substr, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// NOTE: HandleSimpleError testing is covered by TestDetermineErrorCode
|
||||
// since HandleSimpleError calls determineErrorCode and then os.Exit().
|
||||
// Testing os.Exit() directly is not practical in unit tests.
|
||||
|
||||
// TestFatalErrorComponents tests the components used in fatal error handling.
|
||||
// NOTE: We cannot test HandleFatalError directly as it calls os.Exit().
|
||||
// This test verifies that error construction components work correctly.
|
||||
func TestFatalErrorComponents(t *testing.T) {
|
||||
// Test the logic that HandleFatalError uses before calling os.Exit
|
||||
|
||||
handler := newTestErrorHandler()
|
||||
|
||||
// Test that HandleFatalError correctly constructs contextual errors
|
||||
code := appconstants.ErrCodeFileNotFound
|
||||
message := "test error message"
|
||||
context := map[string]string{"file": "test.yml"}
|
||||
|
||||
// Verify suggestions and help URL are retrieved
|
||||
suggestions := apperrors.GetSuggestions(code, context)
|
||||
helpURL := apperrors.GetHelpURL(code)
|
||||
|
||||
// ErrCodeFileNotFound should have suggestions and help URL
|
||||
if len(suggestions) == 0 {
|
||||
t.Errorf("GetSuggestions(%v) returned empty, expected non-empty for ErrCodeFileNotFound", code)
|
||||
}
|
||||
|
||||
if helpURL == "" {
|
||||
t.Errorf("GetHelpURL(%v) returned empty string, expected URL for ErrCodeFileNotFound", code)
|
||||
}
|
||||
|
||||
// Verify error construction (without calling HandleFatalError which exits)
|
||||
contextualErr := apperrors.New(code, message).
|
||||
WithSuggestions(suggestions...).
|
||||
WithHelpURL(helpURL).
|
||||
WithDetails(context)
|
||||
|
||||
if contextualErr == nil {
|
||||
t.Error("failed to construct contextual error")
|
||||
}
|
||||
|
||||
// Verify handler is properly initialized
|
||||
if handler.output == nil {
|
||||
t.Error("handler output is nil")
|
||||
}
|
||||
}
|
||||
@@ -74,13 +74,13 @@ func (tp *TaskProgress) ReportProgress(task string, step int, total int) {
|
||||
}
|
||||
|
||||
// ConfigAwareComponent demonstrates a component that only needs to check configuration.
|
||||
// It depends only on OutputConfig, not the entire output system.
|
||||
// It depends only on QuietChecker, not the entire output system.
|
||||
type ConfigAwareComponent struct {
|
||||
config OutputConfig
|
||||
config QuietChecker
|
||||
}
|
||||
|
||||
// NewConfigAwareComponent creates a component that checks output configuration.
|
||||
func NewConfigAwareComponent(config OutputConfig) *ConfigAwareComponent {
|
||||
func NewConfigAwareComponent(config QuietChecker) *ConfigAwareComponent {
|
||||
return &ConfigAwareComponent{config: config}
|
||||
}
|
||||
|
||||
|
||||
284
internal/focused_consumers_test.go
Normal file
284
internal/focused_consumers_test.go
Normal file
@@ -0,0 +1,284 @@
|
||||
package internal
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/ivuorinen/gh-action-readme/appconstants"
|
||||
"github.com/ivuorinen/gh-action-readme/internal/apperrors"
|
||||
"github.com/ivuorinen/gh-action-readme/testutil"
|
||||
)
|
||||
|
||||
// compositeOutputWriterForTest wraps testutil mocks to satisfy OutputWriter interface.
|
||||
type compositeOutputWriterForTest struct {
|
||||
*testutil.MessageLoggerMock
|
||||
*testutil.ProgressReporterMock
|
||||
*testutil.QuietCheckerMock
|
||||
}
|
||||
|
||||
// errorManagerForTest wraps testutil mocks to satisfy ErrorManager interface.
|
||||
type errorManagerForTest struct {
|
||||
*testutil.ErrorReporterMock
|
||||
*testutil.ErrorFormatterMock
|
||||
}
|
||||
|
||||
// FormatContextualError implements ErrorManager interface.
|
||||
func (e *errorManagerForTest) FormatContextualError(err *apperrors.ContextualError) string {
|
||||
if err != nil {
|
||||
return e.ErrorFormatterMock.FormatContextualError(err)
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
// ErrorWithSuggestions implements ErrorManager interface.
|
||||
func (e *errorManagerForTest) ErrorWithSuggestions(err *apperrors.ContextualError) {
|
||||
e.ErrorReporterMock.ErrorWithSuggestions(err)
|
||||
}
|
||||
|
||||
// TestNewCompositeOutputWriter tests the composite output writer constructor.
|
||||
func TestNewCompositeOutputWriter(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
writer := &compositeOutputWriterForTest{
|
||||
MessageLoggerMock: &testutil.MessageLoggerMock{},
|
||||
ProgressReporterMock: &testutil.ProgressReporterMock{},
|
||||
QuietCheckerMock: &testutil.QuietCheckerMock{},
|
||||
}
|
||||
cow := NewCompositeOutputWriter(writer)
|
||||
|
||||
if cow == nil {
|
||||
t.Fatal("NewCompositeOutputWriter() returned nil")
|
||||
}
|
||||
|
||||
if cow.writer != writer {
|
||||
t.Error("NewCompositeOutputWriter() did not set writer correctly")
|
||||
}
|
||||
}
|
||||
|
||||
// TestCompositeOutputWriterProcessWithOutput tests processing with output.
|
||||
func TestCompositeOutputWriterProcessWithOutput(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
isQuiet bool
|
||||
items []string
|
||||
wantMessages int
|
||||
wantInfo bool
|
||||
wantProgress bool
|
||||
wantSuccess bool
|
||||
}{
|
||||
{
|
||||
name: "with items not quiet",
|
||||
isQuiet: false,
|
||||
items: []string{"item1", "item2", "item3"},
|
||||
wantMessages: 5, // 1 info + 3 progress + 1 success
|
||||
wantInfo: true,
|
||||
wantProgress: true,
|
||||
wantSuccess: true,
|
||||
},
|
||||
{
|
||||
name: "with quiet mode",
|
||||
isQuiet: true,
|
||||
items: []string{"item1", "item2"},
|
||||
wantMessages: 0,
|
||||
wantInfo: false,
|
||||
wantProgress: false,
|
||||
wantSuccess: false,
|
||||
},
|
||||
{
|
||||
name: "with empty items",
|
||||
isQuiet: false,
|
||||
items: []string{},
|
||||
wantMessages: 2, // 1 info + 1 success
|
||||
wantInfo: true,
|
||||
wantProgress: false,
|
||||
wantSuccess: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
logger := &testutil.MessageLoggerMock{}
|
||||
progress := &testutil.ProgressReporterMock{}
|
||||
writer := &compositeOutputWriterForTest{
|
||||
MessageLoggerMock: logger,
|
||||
ProgressReporterMock: progress,
|
||||
QuietCheckerMock: &testutil.QuietCheckerMock{QuietMode: tt.isQuiet},
|
||||
}
|
||||
cow := NewCompositeOutputWriter(writer)
|
||||
|
||||
cow.ProcessWithOutput(tt.items)
|
||||
|
||||
totalMessages := len(logger.InfoCalls) + len(progress.ProgressCalls) + len(logger.SuccessCalls)
|
||||
if totalMessages != tt.wantMessages {
|
||||
t.Errorf("ProcessWithOutput() produced %d messages, want %d",
|
||||
totalMessages, tt.wantMessages)
|
||||
}
|
||||
|
||||
hasInfo := len(logger.InfoCalls) > 0
|
||||
hasProgress := len(progress.ProgressCalls) > 0
|
||||
hasSuccess := len(logger.SuccessCalls) > 0
|
||||
|
||||
if hasInfo != tt.wantInfo {
|
||||
t.Errorf("ProcessWithOutput() hasInfo = %v, want %v", hasInfo, tt.wantInfo)
|
||||
}
|
||||
if hasProgress != tt.wantProgress {
|
||||
t.Errorf("ProcessWithOutput() hasProgress = %v, want %v", hasProgress, tt.wantProgress)
|
||||
}
|
||||
if hasSuccess != tt.wantSuccess {
|
||||
t.Errorf("ProcessWithOutput() hasSuccess = %v, want %v", hasSuccess, tt.wantSuccess)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestNewValidationComponent tests the validation component constructor.
|
||||
func TestNewValidationComponent(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
errorManager := &errorManagerForTest{
|
||||
ErrorReporterMock: &testutil.ErrorReporterMock{},
|
||||
ErrorFormatterMock: &testutil.ErrorFormatterMock{},
|
||||
}
|
||||
logger := &testutil.MessageLoggerMock{}
|
||||
|
||||
vc := NewValidationComponent(errorManager, logger)
|
||||
|
||||
if vc == nil {
|
||||
t.Fatal("NewValidationComponent() returned nil")
|
||||
}
|
||||
|
||||
if vc.errorManager != errorManager {
|
||||
t.Error("NewValidationComponent() did not set errorManager correctly")
|
||||
}
|
||||
|
||||
if vc.logger != logger {
|
||||
t.Error("NewValidationComponent() did not set logger correctly")
|
||||
}
|
||||
}
|
||||
|
||||
// getErrorCallType returns the type of error call that was made.
|
||||
func getErrorCallType(reporter *testutil.ErrorReporterMock) string {
|
||||
switch {
|
||||
case len(reporter.ErrorWithSuggestionsCalls) > 0:
|
||||
return "ErrorWithSuggestions"
|
||||
case len(reporter.ErrorCalls) > 0:
|
||||
return "Error"
|
||||
case len(reporter.ErrorWithSimpleFixCalls) > 0:
|
||||
return "ErrorWithSimpleFix"
|
||||
case len(reporter.ErrorWithContextCalls) > 0:
|
||||
return "ErrorWithContext"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidationComponentValidateAndReport tests validation reporting.
|
||||
func TestValidationComponentValidateAndReport(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
item string
|
||||
isValid bool
|
||||
err error
|
||||
wantLoggerCalls int
|
||||
wantErrorCalls int
|
||||
wantErrorCallType string
|
||||
}{
|
||||
{
|
||||
name: "valid item",
|
||||
item: testutil.TestItemName,
|
||||
isValid: true,
|
||||
err: nil,
|
||||
wantLoggerCalls: 1,
|
||||
wantErrorCalls: 0,
|
||||
wantErrorCallType: "",
|
||||
},
|
||||
{
|
||||
name: "invalid with contextual error",
|
||||
item: testutil.TestItemName,
|
||||
isValid: false,
|
||||
err: apperrors.New(appconstants.ErrCodeValidation, "validation failed"),
|
||||
wantLoggerCalls: 0,
|
||||
wantErrorCalls: 1,
|
||||
wantErrorCallType: "ErrorWithSuggestions",
|
||||
},
|
||||
{
|
||||
name: "invalid with regular error",
|
||||
item: testutil.TestItemName,
|
||||
isValid: false,
|
||||
err: errors.New("regular error"),
|
||||
wantLoggerCalls: 0,
|
||||
wantErrorCalls: 1,
|
||||
wantErrorCallType: "Error",
|
||||
},
|
||||
{
|
||||
name: "invalid without error",
|
||||
item: testutil.TestItemName,
|
||||
isValid: false,
|
||||
err: nil,
|
||||
wantLoggerCalls: 0,
|
||||
wantErrorCalls: 1,
|
||||
wantErrorCallType: "ErrorWithSimpleFix",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
errorReporter := &testutil.ErrorReporterMock{}
|
||||
errorManager := &errorManagerForTest{
|
||||
ErrorReporterMock: errorReporter,
|
||||
ErrorFormatterMock: &testutil.ErrorFormatterMock{},
|
||||
}
|
||||
logger := &testutil.MessageLoggerMock{}
|
||||
vc := NewValidationComponent(errorManager, logger)
|
||||
|
||||
vc.ValidateAndReport(tt.item, tt.isValid, tt.err)
|
||||
|
||||
totalLoggerCalls := len(
|
||||
logger.InfoCalls,
|
||||
) + len(
|
||||
logger.SuccessCalls,
|
||||
) + len(
|
||||
logger.WarningCalls,
|
||||
) + len(
|
||||
logger.BoldCalls,
|
||||
) + len(
|
||||
logger.PrintfCalls,
|
||||
)
|
||||
if totalLoggerCalls != tt.wantLoggerCalls {
|
||||
t.Errorf("ValidateAndReport() logger calls = %d, want %d",
|
||||
totalLoggerCalls, tt.wantLoggerCalls)
|
||||
}
|
||||
|
||||
totalErrorCalls := len(
|
||||
errorReporter.ErrorCalls,
|
||||
) + len(
|
||||
errorReporter.ErrorWithSuggestionsCalls,
|
||||
) + len(
|
||||
errorReporter.ErrorWithContextCalls,
|
||||
) + len(
|
||||
errorReporter.ErrorWithSimpleFixCalls,
|
||||
)
|
||||
if totalErrorCalls != tt.wantErrorCalls {
|
||||
t.Errorf("ValidateAndReport() error calls = %d, want %d",
|
||||
totalErrorCalls, tt.wantErrorCalls)
|
||||
}
|
||||
|
||||
if tt.wantErrorCallType != "" {
|
||||
actualCallType := getErrorCallType(errorReporter)
|
||||
if actualCallType != tt.wantErrorCallType {
|
||||
t.Errorf("ValidateAndReport() error call type = %s, want %s",
|
||||
actualCallType, tt.wantErrorCallType)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -48,7 +48,13 @@ func isUnitTestEnvironment() bool {
|
||||
// NewGenerator creates a new generator instance with the provided configuration.
|
||||
// This constructor maintains backward compatibility by using concrete implementations.
|
||||
// In unit test environments, it automatically uses NullOutput to suppress output.
|
||||
// If config is nil, it uses DefaultAppConfig() to prevent panics.
|
||||
func NewGenerator(config *AppConfig) *Generator {
|
||||
// Handle nil config gracefully
|
||||
if config == nil {
|
||||
config = DefaultAppConfig()
|
||||
}
|
||||
|
||||
// Use null output in unit test environments to keep tests clean
|
||||
// Integration tests need real output to verify CLI behavior
|
||||
if isUnitTestEnvironment() {
|
||||
@@ -139,8 +145,8 @@ func (g *Generator) GenerateFromFile(actionPath string) error {
|
||||
|
||||
// DiscoverActionFiles finds action.yml and action.yaml files in the given directory
|
||||
// using the centralized parser function and adds verbose logging.
|
||||
func (g *Generator) DiscoverActionFiles(dir string, recursive bool) ([]string, error) {
|
||||
actionFiles, err := DiscoverActionFiles(dir, recursive)
|
||||
func (g *Generator) DiscoverActionFiles(dir string, recursive bool, ignoredDirs []string) ([]string, error) {
|
||||
actionFiles, err := DiscoverActionFiles(dir, recursive, ignoredDirs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -161,9 +167,14 @@ func (g *Generator) DiscoverActionFiles(dir string, recursive bool) ([]string, e
|
||||
|
||||
// DiscoverActionFilesWithValidation discovers action files with centralized error handling and validation.
|
||||
// This function consolidates the duplicated file discovery logic across the codebase.
|
||||
func (g *Generator) DiscoverActionFilesWithValidation(dir string, recursive bool, context string) ([]string, error) {
|
||||
func (g *Generator) DiscoverActionFilesWithValidation(
|
||||
dir string,
|
||||
recursive bool,
|
||||
ignoredDirs []string,
|
||||
context string,
|
||||
) ([]string, error) {
|
||||
// Discover action files
|
||||
actionFiles, err := g.DiscoverActionFiles(dir, recursive)
|
||||
actionFiles, err := g.DiscoverActionFiles(dir, recursive, ignoredDirs)
|
||||
if err != nil {
|
||||
g.Output.ErrorWithContext(
|
||||
appconstants.ErrCodeFileNotFound,
|
||||
@@ -284,31 +295,47 @@ func (g *Generator) renderTemplateForAction(
|
||||
return content, nil
|
||||
}
|
||||
|
||||
// generateMarkdown creates a README.md file using the template.
|
||||
func (g *Generator) generateMarkdown(action *ActionYML, outputDir, actionPath string) error {
|
||||
// generateSimpleFormat is a helper for generating simple text-based formats (Markdown, AsciiDoc).
|
||||
// It consolidates the common pattern of template rendering, file writing, and success messaging.
|
||||
func (g *Generator) generateSimpleFormat(
|
||||
action *ActionYML,
|
||||
outputDir, actionPath string,
|
||||
format, defaultFilename, successMsg string,
|
||||
) error {
|
||||
templatePath := g.resolveTemplatePathForFormat()
|
||||
|
||||
opts := TemplateOptions{
|
||||
TemplatePath: templatePath,
|
||||
Format: "md",
|
||||
Format: format,
|
||||
}
|
||||
|
||||
content, err := g.renderTemplateForAction(action, outputDir, actionPath, opts)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to render markdown template: %w", err)
|
||||
return fmt.Errorf("failed to render %s template: %w", format, err)
|
||||
}
|
||||
|
||||
outputPath := g.resolveOutputPath(outputDir, appconstants.ReadmeMarkdown)
|
||||
outputPath, err := g.resolveOutputPath(outputDir, defaultFilename)
|
||||
if err != nil {
|
||||
return fmt.Errorf(appconstants.ErrFailedToResolveOutputPath, err)
|
||||
}
|
||||
if err := os.WriteFile(outputPath, []byte(content), appconstants.FilePermDefault); err != nil {
|
||||
// #nosec G306 -- output file permissions
|
||||
return fmt.Errorf("failed to write README.md to %s: %w", outputPath, err)
|
||||
return fmt.Errorf("failed to write %s to %s: %w", format, outputPath, err)
|
||||
}
|
||||
|
||||
g.Output.Success("Generated README.md: %s", outputPath)
|
||||
g.Output.Success("%s: %s", successMsg, outputPath)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// generateMarkdown creates a README.md file using the template.
|
||||
func (g *Generator) generateMarkdown(action *ActionYML, outputDir, actionPath string) error {
|
||||
return g.generateSimpleFormat(
|
||||
action, outputDir, actionPath,
|
||||
"md", appconstants.ReadmeMarkdown, "Generated README.md",
|
||||
)
|
||||
}
|
||||
|
||||
// generateHTML creates an HTML file using the template and optional header/footer.
|
||||
func (g *Generator) generateHTML(action *ActionYML, outputDir, actionPath string) error {
|
||||
templatePath := g.resolveTemplatePathForFormat()
|
||||
@@ -332,7 +359,10 @@ func (g *Generator) generateHTML(action *ActionYML, outputDir, actionPath string
|
||||
}
|
||||
|
||||
defaultFilename := action.Name + ".html"
|
||||
outputPath := g.resolveOutputPath(outputDir, defaultFilename)
|
||||
outputPath, err := g.resolveOutputPath(outputDir, defaultFilename)
|
||||
if err != nil {
|
||||
return fmt.Errorf(appconstants.ErrFailedToResolveOutputPath, err)
|
||||
}
|
||||
if err := writer.Write(content, outputPath); err != nil {
|
||||
return fmt.Errorf("failed to write HTML to %s: %w", outputPath, err)
|
||||
}
|
||||
@@ -346,7 +376,10 @@ func (g *Generator) generateHTML(action *ActionYML, outputDir, actionPath string
|
||||
func (g *Generator) generateJSON(action *ActionYML, outputDir string) error {
|
||||
writer := NewJSONWriter(g.Config)
|
||||
|
||||
outputPath := g.resolveOutputPath(outputDir, appconstants.ActionDocsJSON)
|
||||
outputPath, err := g.resolveOutputPath(outputDir, appconstants.ActionDocsJSON)
|
||||
if err != nil {
|
||||
return fmt.Errorf(appconstants.ErrFailedToResolveOutputPath, err)
|
||||
}
|
||||
if err := writer.Write(action, outputPath); err != nil {
|
||||
return fmt.Errorf("failed to write JSON to %s: %w", outputPath, err)
|
||||
}
|
||||
@@ -358,27 +391,10 @@ func (g *Generator) generateJSON(action *ActionYML, outputDir string) error {
|
||||
|
||||
// generateASCIIDoc creates an AsciiDoc file using the template.
|
||||
func (g *Generator) generateASCIIDoc(action *ActionYML, outputDir, actionPath string) error {
|
||||
templatePath := g.resolveTemplatePathForFormat()
|
||||
|
||||
opts := TemplateOptions{
|
||||
TemplatePath: templatePath,
|
||||
Format: "asciidoc",
|
||||
}
|
||||
|
||||
content, err := g.renderTemplateForAction(action, outputDir, actionPath, opts)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to render AsciiDoc template: %w", err)
|
||||
}
|
||||
|
||||
outputPath := g.resolveOutputPath(outputDir, appconstants.ReadmeASCIIDoc)
|
||||
if err := os.WriteFile(outputPath, []byte(content), appconstants.FilePermDefault); err != nil {
|
||||
// #nosec G306 -- output file permissions
|
||||
return fmt.Errorf("failed to write AsciiDoc to %s: %w", outputPath, err)
|
||||
}
|
||||
|
||||
g.Output.Success("Generated AsciiDoc: %s", outputPath)
|
||||
|
||||
return nil
|
||||
return g.generateSimpleFormat(
|
||||
action, outputDir, actionPath,
|
||||
"asciidoc", appconstants.ReadmeASCIIDoc, "Generated AsciiDoc",
|
||||
)
|
||||
}
|
||||
|
||||
// processFiles processes each file and tracks results.
|
||||
@@ -463,17 +479,56 @@ func (g *Generator) determineOutputDir(actionPath string) string {
|
||||
return g.Config.OutputDir
|
||||
}
|
||||
|
||||
// resolveOutputPath resolves the final output path, considering custom filename.
|
||||
func (g *Generator) resolveOutputPath(outputDir, defaultFilename string) string {
|
||||
// resolveOutputPath resolves the final output path and validates it prevents path traversal.
|
||||
// Returns an error if the resolved path would escape the outputDir.
|
||||
func (g *Generator) resolveOutputPath(outputDir, defaultFilename string) (string, error) {
|
||||
// Determine the filename to use
|
||||
filename := defaultFilename
|
||||
if g.Config.OutputFilename != "" {
|
||||
if filepath.IsAbs(g.Config.OutputFilename) {
|
||||
return g.Config.OutputFilename
|
||||
}
|
||||
|
||||
return filepath.Join(outputDir, g.Config.OutputFilename)
|
||||
filename = g.Config.OutputFilename
|
||||
}
|
||||
|
||||
return filepath.Join(outputDir, defaultFilename)
|
||||
// Reject paths containing .. components (path traversal attempt)
|
||||
if strings.Contains(filename, "..") {
|
||||
return "", fmt.Errorf(appconstants.ErrPathTraversal, filename, outputDir)
|
||||
}
|
||||
|
||||
// Handle absolute paths - allow them as-is (user's explicit choice)
|
||||
if filepath.IsAbs(filename) {
|
||||
cleaned := filepath.Clean(filename)
|
||||
if cleaned != filename {
|
||||
return "", fmt.Errorf("absolute path contains extraneous components: %s", filename)
|
||||
}
|
||||
|
||||
return cleaned, nil
|
||||
}
|
||||
|
||||
// For relative paths, join with output directory
|
||||
finalPath := filepath.Join(outputDir, filename)
|
||||
|
||||
// Validate the final path stays within outputDir
|
||||
absOutputDir, err := filepath.Abs(outputDir)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf(appconstants.ErrInvalidOutputPath, err)
|
||||
}
|
||||
|
||||
absFinalPath, err := filepath.Abs(finalPath)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf(appconstants.ErrInvalidOutputPath, err)
|
||||
}
|
||||
|
||||
// Check if final path is within output directory using filepath.Rel
|
||||
relPath, err := filepath.Rel(absOutputDir, absFinalPath)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf(appconstants.ErrInvalidOutputPath, err)
|
||||
}
|
||||
|
||||
// If relative path starts with "..", it's outside the output directory
|
||||
if strings.HasPrefix(relPath, "..") {
|
||||
return "", fmt.Errorf(appconstants.ErrPathTraversal, filename, outputDir)
|
||||
}
|
||||
|
||||
return absFinalPath, nil
|
||||
}
|
||||
|
||||
// generateByFormat generates documentation in the specified format.
|
||||
|
||||
@@ -5,12 +5,13 @@ import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/ivuorinen/gh-action-readme/appconstants"
|
||||
"github.com/ivuorinen/gh-action-readme/testutil"
|
||||
)
|
||||
|
||||
// TestGenerator_ComprehensiveGeneration demonstrates the new table-driven testing framework
|
||||
// TestGeneratorComprehensiveGeneration demonstrates the new table-driven testing framework
|
||||
// by testing generation across all fixtures, themes, and formats systematically.
|
||||
func TestGenerator_ComprehensiveGeneration(t *testing.T) {
|
||||
func TestGeneratorComprehensiveGeneration(t *testing.T) {
|
||||
t.Parallel()
|
||||
// Create test cases using the new helper functions
|
||||
cases := testutil.CreateGeneratorTestCases()
|
||||
@@ -32,8 +33,8 @@ func TestGenerator_ComprehensiveGeneration(t *testing.T) {
|
||||
testutil.RunGeneratorTests(t, filteredCases)
|
||||
}
|
||||
|
||||
// TestGenerator_AllValidFixtures tests generation with all valid fixtures.
|
||||
func TestGenerator_AllValidFixtures(t *testing.T) {
|
||||
// TestGeneratorAllValidFixtures tests generation with all valid fixtures.
|
||||
func TestGeneratorAllValidFixtures(t *testing.T) {
|
||||
t.Parallel()
|
||||
validFixtures := testutil.GetValidFixtures()
|
||||
|
||||
@@ -64,8 +65,8 @@ func TestGenerator_AllValidFixtures(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestGenerator_AllInvalidFixtures tests that invalid fixtures produce expected errors.
|
||||
func TestGenerator_AllInvalidFixtures(t *testing.T) {
|
||||
// TestGeneratorAllInvalidFixtures tests that invalid fixtures produce expected errors.
|
||||
func TestGeneratorAllInvalidFixtures(t *testing.T) {
|
||||
t.Parallel()
|
||||
invalidFixtures := testutil.GetInvalidFixtures()
|
||||
|
||||
@@ -106,8 +107,8 @@ func TestGenerator_AllInvalidFixtures(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestGenerator_AllThemes demonstrates theme testing using helper functions.
|
||||
func TestGenerator_AllThemes(t *testing.T) {
|
||||
// TestGeneratorAllThemes demonstrates theme testing using helper functions.
|
||||
func TestGeneratorAllThemes(t *testing.T) {
|
||||
t.Parallel()
|
||||
// Use the helper function to test all themes
|
||||
testutil.TestAllThemes(t, func(t *testing.T, theme string) {
|
||||
@@ -129,8 +130,8 @@ func TestGenerator_AllThemes(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
// TestGenerator_AllFormats demonstrates format testing using helper functions.
|
||||
func TestGenerator_AllFormats(t *testing.T) {
|
||||
// TestGeneratorAllFormats demonstrates format testing using helper functions.
|
||||
func TestGeneratorAllFormats(t *testing.T) {
|
||||
t.Parallel()
|
||||
// Use the helper function to test all formats
|
||||
testutil.TestAllFormats(t, func(t *testing.T, format string) {
|
||||
@@ -152,8 +153,8 @@ func TestGenerator_AllFormats(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
// TestGenerator_ByActionType demonstrates testing by action type.
|
||||
func TestGenerator_ByActionType(t *testing.T) {
|
||||
// TestGeneratorByActionType demonstrates testing by action type.
|
||||
func TestGeneratorByActionType(t *testing.T) {
|
||||
t.Parallel()
|
||||
actionTypes := []testutil.ActionType{
|
||||
testutil.ActionTypeJavaScript,
|
||||
@@ -190,8 +191,8 @@ func TestGenerator_ByActionType(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestGenerator_WithMockEnvironment demonstrates testing with a complete mock environment.
|
||||
func TestGenerator_WithMockEnvironment(t *testing.T) {
|
||||
// TestGeneratorWithMockEnvironment demonstrates testing with a complete mock environment.
|
||||
func TestGeneratorWithMockEnvironment(t *testing.T) {
|
||||
t.Parallel()
|
||||
// Create a complete test environment
|
||||
envConfig := &testutil.EnvironmentConfig{
|
||||
@@ -227,8 +228,8 @@ func TestGenerator_WithMockEnvironment(t *testing.T) {
|
||||
testutil.AssertNoError(t, err)
|
||||
}
|
||||
|
||||
// TestGenerator_FixtureValidation demonstrates fixture validation.
|
||||
func TestGenerator_FixtureValidation(t *testing.T) {
|
||||
// TestGeneratorFixtureValidation demonstrates fixture validation.
|
||||
func TestGeneratorFixtureValidation(t *testing.T) {
|
||||
t.Parallel()
|
||||
// Test that all valid fixtures pass validation
|
||||
validFixtures := testutil.GetValidFixtures()
|
||||
@@ -271,7 +272,7 @@ func createGeneratorTestExecutor() testutil.TestExecutor {
|
||||
}
|
||||
|
||||
// Create temporary action file
|
||||
actionPath = filepath.Join(ctx.TempDir, "action.yml")
|
||||
actionPath = filepath.Join(ctx.TempDir, appconstants.ActionFileNameYML)
|
||||
testutil.WriteTestFile(t, actionPath, fixture.Content)
|
||||
}
|
||||
|
||||
|
||||
139
internal/generator_helper_test.go
Normal file
139
internal/generator_helper_test.go
Normal file
@@ -0,0 +1,139 @@
|
||||
package internal
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/ivuorinen/gh-action-readme/appconstants"
|
||||
)
|
||||
|
||||
// TestDefaultTestConfig_Helper tests the defaultTestConfig helper function.
|
||||
func TestDefaultTestConfigHelper(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Call the helper multiple times to verify consistency
|
||||
cfg1 := defaultTestConfig()
|
||||
cfg2 := defaultTestConfig()
|
||||
|
||||
// Verify expected defaults
|
||||
if cfg1.Quiet != true {
|
||||
t.Error("expected Quiet=true for test config")
|
||||
}
|
||||
if cfg1.Theme != appconstants.ThemeDefault {
|
||||
t.Errorf("expected default theme, got %s", cfg1.Theme)
|
||||
}
|
||||
if cfg1.OutputFormat != appconstants.OutputFormatMarkdown {
|
||||
t.Errorf("expected markdown format, got %s", cfg1.OutputFormat)
|
||||
}
|
||||
if cfg1.OutputDir != "." {
|
||||
t.Errorf("expected OutputDir='.', got %s", cfg1.OutputDir)
|
||||
}
|
||||
|
||||
// Verify immutability - modifying one shouldn't affect others
|
||||
cfg1.Quiet = false
|
||||
cfg1.Theme = "custom"
|
||||
|
||||
if cfg2.Quiet != true {
|
||||
t.Error("defaultTestConfig should return independent configs")
|
||||
}
|
||||
if cfg2.Theme != appconstants.ThemeDefault {
|
||||
t.Error("defaultTestConfig should return independent configs")
|
||||
}
|
||||
|
||||
// Verify getting a fresh config after modification
|
||||
cfg3 := defaultTestConfig()
|
||||
if cfg3.Quiet != true {
|
||||
t.Error("defaultTestConfig should always return Quiet=true")
|
||||
}
|
||||
}
|
||||
|
||||
// TestAssertActionFiles_Helper tests the assertActionFiles helper function.
|
||||
func TestAssertActionFilesHelper(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
files []string
|
||||
setup func(*testing.T) []string
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "empty file list",
|
||||
setup: func(t *testing.T) []string {
|
||||
t.Helper()
|
||||
|
||||
return []string{}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "valid action.yml files",
|
||||
setup: func(t *testing.T) []string {
|
||||
t.Helper()
|
||||
tmpDir1 := t.TempDir()
|
||||
tmpDir2 := t.TempDir()
|
||||
file1 := filepath.Join(tmpDir1, appconstants.ActionFileNameYML)
|
||||
file2 := filepath.Join(tmpDir2, appconstants.ActionFileNameYML)
|
||||
|
||||
err := os.WriteFile(file1, []byte("name: test"), appconstants.FilePermDefault)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to write file1: %v", err)
|
||||
}
|
||||
|
||||
err = os.WriteFile(file2, []byte("name: test2"), appconstants.FilePermDefault)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to write file2: %v", err)
|
||||
}
|
||||
|
||||
return []string{file1, file2}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "valid action.yaml files",
|
||||
setup: func(t *testing.T) []string {
|
||||
t.Helper()
|
||||
tmpDir := t.TempDir()
|
||||
file := filepath.Join(tmpDir, "action.yaml")
|
||||
|
||||
err := os.WriteFile(file, []byte("name: test"), appconstants.FilePermDefault)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to write file: %v", err)
|
||||
}
|
||||
|
||||
return []string{file}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "mixed yml and yaml extensions",
|
||||
setup: func(t *testing.T) []string {
|
||||
t.Helper()
|
||||
tmpDir1 := t.TempDir()
|
||||
tmpDir2 := t.TempDir()
|
||||
file1 := filepath.Join(tmpDir1, appconstants.ActionFileNameYML)
|
||||
file2 := filepath.Join(tmpDir2, "action.yaml")
|
||||
|
||||
_ = os.WriteFile(file1, []byte("name: test1"), appconstants.FilePermDefault)
|
||||
|
||||
_ = os.WriteFile(file2, []byte("name: test2"), appconstants.FilePermDefault)
|
||||
|
||||
return []string{file1, file2}
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
files := tt.setup(t)
|
||||
|
||||
// Call the helper - it will verify files exist and have correct extensions
|
||||
// For invalid files, it will call t.Error (which is expected)
|
||||
assertActionFiles(t, files)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Note: Invalid test cases (wrong extensions, nonexistent files) are not included
|
||||
// because testing error paths would require mocking testing.T, which is complex.
|
||||
// The helper is already well-tested through the main test suite for error cases.
|
||||
File diff suppressed because it is too large
Load Diff
153
internal/generator_test_helper.go
Normal file
153
internal/generator_test_helper.go
Normal file
@@ -0,0 +1,153 @@
|
||||
package internal
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/ivuorinen/gh-action-readme/appconstants"
|
||||
"github.com/ivuorinen/gh-action-readme/testutil"
|
||||
)
|
||||
|
||||
// testFormatGeneration is a generic helper for testing format generation methods.
|
||||
// It consolidates the common pattern across HTML, JSON, and AsciiDoc generation tests.
|
||||
func testFormatGeneration(
|
||||
t *testing.T,
|
||||
generateFunc func(*Generator, *ActionYML, string, string) error,
|
||||
expectedFile, formatName string,
|
||||
needsActionPath bool,
|
||||
) {
|
||||
t.Helper()
|
||||
t.Parallel()
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
action := createTestAction()
|
||||
gen := createQuietGenerator()
|
||||
|
||||
var err error
|
||||
if needsActionPath {
|
||||
actionPath := filepath.Join(tmpDir, appconstants.ActionFileNameYML)
|
||||
err = generateFunc(gen, action, tmpDir, actionPath)
|
||||
} else {
|
||||
// For JSON which doesn't need actionPath
|
||||
err = generateFunc(gen, action, tmpDir, "")
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
t.Errorf("%s generation unexpected error = %v", formatName, err)
|
||||
}
|
||||
|
||||
verifyFileExists(t, filepath.Join(tmpDir, expectedFile), expectedFile)
|
||||
}
|
||||
|
||||
// testHTMLGeneration tests HTML generation creates the expected output file.
|
||||
func testHTMLGeneration(t *testing.T) {
|
||||
t.Helper()
|
||||
|
||||
testFormatGeneration(
|
||||
t,
|
||||
func(g *Generator, a *ActionYML, out, path string) error {
|
||||
return g.generateHTML(a, out, path)
|
||||
},
|
||||
"Test Action.html",
|
||||
"HTML",
|
||||
true, // needs actionPath
|
||||
)
|
||||
}
|
||||
|
||||
// testJSONGeneration tests JSON generation creates the expected output file.
|
||||
func testJSONGeneration(t *testing.T) {
|
||||
t.Helper()
|
||||
|
||||
testFormatGeneration(
|
||||
t,
|
||||
func(g *Generator, a *ActionYML, out, _ string) error {
|
||||
return g.generateJSON(a, out)
|
||||
},
|
||||
"action-docs.json",
|
||||
"JSON",
|
||||
false, // doesn't need actionPath
|
||||
)
|
||||
}
|
||||
|
||||
// testASCIIDocGeneration tests AsciiDoc generation creates the expected output file.
|
||||
func testASCIIDocGeneration(t *testing.T) {
|
||||
t.Helper()
|
||||
|
||||
testFormatGeneration(
|
||||
t,
|
||||
func(g *Generator, a *ActionYML, out, path string) error {
|
||||
return g.generateASCIIDoc(a, out, path)
|
||||
},
|
||||
"README.adoc",
|
||||
"AsciiDoc",
|
||||
true, // needs actionPath
|
||||
)
|
||||
}
|
||||
|
||||
// createTestAction creates a basic test action for generator tests.
|
||||
func createTestAction() *ActionYML {
|
||||
return &ActionYML{
|
||||
Name: testutil.TestActionName,
|
||||
Description: testutil.TestActionDesc,
|
||||
Runs: map[string]any{"using": "composite"},
|
||||
}
|
||||
}
|
||||
|
||||
// createQuietGenerator creates a generator with quiet output for testing.
|
||||
func createQuietGenerator() *Generator {
|
||||
config := DefaultAppConfig()
|
||||
config.Quiet = true
|
||||
|
||||
return NewGenerator(config)
|
||||
}
|
||||
|
||||
// verifyFileExists checks that a file was created at the expected path.
|
||||
func verifyFileExists(t *testing.T, fullPath, expectedFileName string) {
|
||||
t.Helper()
|
||||
|
||||
if _, err := os.Stat(fullPath); os.IsNotExist(err) {
|
||||
t.Errorf("Expected %s to be created", expectedFileName)
|
||||
}
|
||||
}
|
||||
|
||||
// createTestDirs creates multiple test directories with given names.
|
||||
func createTestDirs(t *testing.T, tmpDir string, names ...string) []string {
|
||||
t.Helper()
|
||||
dirs := make([]string, len(names))
|
||||
for i, name := range names {
|
||||
dirPath := filepath.Join(tmpDir, name)
|
||||
testutil.CreateTestDir(t, dirPath)
|
||||
dirs[i] = dirPath
|
||||
}
|
||||
|
||||
return dirs
|
||||
}
|
||||
|
||||
// createMultiActionSetup creates a setupFunc for batch processing tests with multiple actions.
|
||||
// It generates separate directories for each action and writes the specified fixtures.
|
||||
func createMultiActionSetup(dirNames, fixtures []string) func(t *testing.T, tmpDir string) []string {
|
||||
return func(t *testing.T, tmpDir string) []string {
|
||||
t.Helper()
|
||||
|
||||
// Create separate directories for each action
|
||||
dirs := createTestDirs(t, tmpDir, dirNames...)
|
||||
|
||||
// Build file paths and write fixtures
|
||||
files := make([]string, len(dirs))
|
||||
for i, dir := range dirs {
|
||||
files[i] = filepath.Join(dir, appconstants.ActionFileNameYML)
|
||||
testutil.WriteTestFile(t, files[i], testutil.MustReadFixture(fixtures[i]))
|
||||
}
|
||||
|
||||
return files
|
||||
}
|
||||
}
|
||||
|
||||
// setupNonexistentFiles returns a setupFunc that creates paths to nonexistent files.
|
||||
// This is used in multiple tests to verify error handling for missing files.
|
||||
func setupNonexistentFiles(filename string) func(*testing.T, string) []string {
|
||||
return func(_ *testing.T, tmpDir string) []string {
|
||||
return []string{filepath.Join(tmpDir, filename)}
|
||||
}
|
||||
}
|
||||
85
internal/generator_validation_helper_test.go
Normal file
85
internal/generator_validation_helper_test.go
Normal file
@@ -0,0 +1,85 @@
|
||||
package internal
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/ivuorinen/gh-action-readme/testutil"
|
||||
)
|
||||
|
||||
// TestAssertMessageCounts_Helper tests the assertMessageCounts helper function.
|
||||
func TestAssertMessageCountsHelper(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
output *capturedOutput
|
||||
want messageCountExpectations
|
||||
}{
|
||||
{
|
||||
name: "all counts zero",
|
||||
output: &capturedOutput{
|
||||
CapturedOutput: &testutil.CapturedOutput{
|
||||
BoldMessages: []string{},
|
||||
SuccessMessages: []string{},
|
||||
WarningMessages: []string{},
|
||||
ErrorMessages: []string{},
|
||||
InfoMessages: []string{},
|
||||
},
|
||||
},
|
||||
want: messageCountExpectations{
|
||||
bold: 0,
|
||||
success: 0,
|
||||
warning: 0,
|
||||
error: 0,
|
||||
info: 0,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "some messages",
|
||||
output: &capturedOutput{
|
||||
CapturedOutput: &testutil.CapturedOutput{
|
||||
BoldMessages: []string{"bold1", "bold2"},
|
||||
SuccessMessages: []string{"success1"},
|
||||
WarningMessages: []string{},
|
||||
ErrorMessages: []string{"error1", "error2", "error3"},
|
||||
InfoMessages: []string{"info1"},
|
||||
},
|
||||
},
|
||||
want: messageCountExpectations{
|
||||
bold: 2,
|
||||
success: 1,
|
||||
warning: 0,
|
||||
error: 3,
|
||||
info: 1,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "only bold and success",
|
||||
output: &capturedOutput{
|
||||
CapturedOutput: &testutil.CapturedOutput{
|
||||
BoldMessages: []string{"bold"},
|
||||
SuccessMessages: []string{"success"},
|
||||
WarningMessages: []string{},
|
||||
ErrorMessages: []string{},
|
||||
InfoMessages: []string{},
|
||||
},
|
||||
},
|
||||
want: messageCountExpectations{
|
||||
bold: 1,
|
||||
success: 1,
|
||||
warning: 0,
|
||||
error: 0,
|
||||
info: 0,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Call the helper - it validates message counts
|
||||
assertMessageCounts(t, tt.output, tt.want)
|
||||
})
|
||||
}
|
||||
}
|
||||
551
internal/generator_validation_test.go
Normal file
551
internal/generator_validation_test.go
Normal file
@@ -0,0 +1,551 @@
|
||||
package internal
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/ivuorinen/gh-action-readme/appconstants"
|
||||
"github.com/ivuorinen/gh-action-readme/internal/apperrors"
|
||||
"github.com/ivuorinen/gh-action-readme/testutil"
|
||||
)
|
||||
|
||||
// capturedOutput wraps testutil.CapturedOutput to satisfy CompleteOutput interface.
|
||||
type capturedOutput struct {
|
||||
*testutil.CapturedOutput
|
||||
}
|
||||
|
||||
// ErrorWithSuggestions wraps the testutil version to match interface signature.
|
||||
func (c *capturedOutput) ErrorWithSuggestions(err *apperrors.ContextualError) {
|
||||
c.CapturedOutput.ErrorWithSuggestions(err)
|
||||
}
|
||||
|
||||
// FormatContextualError wraps the testutil version to match interface signature.
|
||||
func (c *capturedOutput) FormatContextualError(err *apperrors.ContextualError) string {
|
||||
return c.CapturedOutput.FormatContextualError(err)
|
||||
}
|
||||
|
||||
// newCapturedOutput creates a new capturedOutput instance.
|
||||
func newCapturedOutput() *capturedOutput {
|
||||
return &capturedOutput{
|
||||
CapturedOutput: &testutil.CapturedOutput{},
|
||||
}
|
||||
}
|
||||
|
||||
// TestCountValidationStats tests the validation statistics counting function.
|
||||
func TestCountValidationStats(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
results []ValidationResult
|
||||
wantValidFiles int
|
||||
wantTotalIssues int
|
||||
}{
|
||||
{
|
||||
name: testutil.TestCaseNameAllValidFiles,
|
||||
results: []ValidationResult{
|
||||
{MissingFields: []string{testutil.ValidationTestFile1}},
|
||||
{MissingFields: []string{testutil.ValidationTestFile2}},
|
||||
},
|
||||
wantValidFiles: 2,
|
||||
wantTotalIssues: 0,
|
||||
},
|
||||
{
|
||||
name: "all invalid files",
|
||||
results: []ValidationResult{
|
||||
{MissingFields: []string{testutil.ValidationTestFile1, "name", "description"}},
|
||||
{MissingFields: []string{testutil.ValidationTestFile2, "runs"}},
|
||||
},
|
||||
wantValidFiles: 0,
|
||||
wantTotalIssues: 3, // 2 issues in first file + 1 in second
|
||||
},
|
||||
{
|
||||
name: "mixed valid and invalid",
|
||||
results: []ValidationResult{
|
||||
{MissingFields: []string{testutil.ValidationTestFile1}}, // Valid
|
||||
{MissingFields: []string{testutil.ValidationTestFile2, "name", "description"}}, // 2 issues
|
||||
{MissingFields: []string{"file: action3.yml"}}, // Valid
|
||||
{MissingFields: []string{"file: action4.yml", "runs"}}, // 1 issue
|
||||
},
|
||||
wantValidFiles: 2,
|
||||
wantTotalIssues: 3,
|
||||
},
|
||||
{
|
||||
name: "empty results",
|
||||
results: []ValidationResult{},
|
||||
wantValidFiles: 0,
|
||||
wantTotalIssues: 0,
|
||||
},
|
||||
{
|
||||
name: "single valid file",
|
||||
results: []ValidationResult{
|
||||
{MissingFields: []string{testutil.ValidationTestFile3}},
|
||||
},
|
||||
wantValidFiles: 1,
|
||||
wantTotalIssues: 0,
|
||||
},
|
||||
{
|
||||
name: "single invalid file with multiple issues",
|
||||
results: []ValidationResult{
|
||||
{MissingFields: []string{testutil.ValidationTestFile3, "name", "description", "runs"}},
|
||||
},
|
||||
wantValidFiles: 0,
|
||||
wantTotalIssues: 3,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
gen := &Generator{}
|
||||
gotValid, gotIssues := gen.countValidationStats(tt.results)
|
||||
|
||||
if gotValid != tt.wantValidFiles {
|
||||
t.Errorf("countValidationStats() validFiles = %d, want %d", gotValid, tt.wantValidFiles)
|
||||
}
|
||||
if gotIssues != tt.wantTotalIssues {
|
||||
t.Errorf("countValidationStats() totalIssues = %d, want %d", gotIssues, tt.wantTotalIssues)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// messageCountExpectations defines expected message counts for validation tests.
|
||||
type messageCountExpectations struct {
|
||||
bold int
|
||||
success int
|
||||
warning int
|
||||
error int
|
||||
info int
|
||||
}
|
||||
|
||||
// assertMessageCounts checks that message counts match expectations.
|
||||
func assertMessageCounts(t *testing.T, output *capturedOutput, want messageCountExpectations) {
|
||||
t.Helper()
|
||||
|
||||
checks := []struct {
|
||||
name string
|
||||
got int
|
||||
expected int
|
||||
}{
|
||||
{"bold messages", len(output.BoldMessages), want.bold},
|
||||
{"success messages", len(output.SuccessMessages), want.success},
|
||||
{"warning messages", len(output.WarningMessages), want.warning},
|
||||
{"error messages", len(output.ErrorMessages), want.error},
|
||||
{"info messages", len(output.InfoMessages), want.info},
|
||||
}
|
||||
|
||||
for _, check := range checks {
|
||||
if check.got != check.expected {
|
||||
t.Errorf("showValidationSummary() %s = %d, want %d", check.name, check.got, check.expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestShowValidationSummary tests the validation summary display function.
|
||||
func TestShowValidationSummary(t *testing.T) {
|
||||
tests := []validationSummaryTestCase{
|
||||
createValidationSummaryTest(validationSummaryParams{
|
||||
name: testutil.TestCaseNameAllValidFiles,
|
||||
totalFiles: 3,
|
||||
validFiles: 3,
|
||||
totalIssues: 0,
|
||||
resultCount: 3,
|
||||
errorCount: 0,
|
||||
wantWarning: 0,
|
||||
wantError: 0,
|
||||
wantInfo: 0,
|
||||
}),
|
||||
createValidationSummaryTest(validationSummaryParams{
|
||||
name: "some files with issues",
|
||||
totalFiles: 3,
|
||||
validFiles: 1,
|
||||
totalIssues: 5,
|
||||
resultCount: 3,
|
||||
errorCount: 0,
|
||||
wantWarning: 1,
|
||||
wantError: 0,
|
||||
wantInfo: 1,
|
||||
}),
|
||||
createValidationSummaryTest(validationSummaryParams{
|
||||
name: "parse errors present",
|
||||
totalFiles: 5,
|
||||
validFiles: 2,
|
||||
totalIssues: 3,
|
||||
resultCount: 3,
|
||||
errorCount: 2,
|
||||
wantWarning: 1,
|
||||
wantError: 1,
|
||||
wantInfo: 1,
|
||||
}),
|
||||
createValidationSummaryTest(validationSummaryParams{
|
||||
name: "only parse errors",
|
||||
totalFiles: 2,
|
||||
validFiles: 0,
|
||||
totalIssues: 0,
|
||||
resultCount: 0,
|
||||
errorCount: 2,
|
||||
wantWarning: 0,
|
||||
wantError: 1,
|
||||
wantInfo: 0,
|
||||
}),
|
||||
createValidationSummaryTest(validationSummaryParams{
|
||||
name: testutil.TestCaseNameZeroFiles,
|
||||
totalFiles: 0,
|
||||
validFiles: 0,
|
||||
totalIssues: 0,
|
||||
resultCount: 0,
|
||||
errorCount: 0,
|
||||
wantWarning: 0,
|
||||
wantError: 0,
|
||||
wantInfo: 0,
|
||||
}),
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
output := newCapturedOutput()
|
||||
gen := &Generator{Output: output}
|
||||
|
||||
gen.showValidationSummary(tt.totalFiles, tt.validFiles, tt.totalIssues, tt.resultCount, tt.errorCount)
|
||||
|
||||
assertMessageCounts(t, output, messageCountExpectations{
|
||||
bold: tt.wantBold,
|
||||
success: tt.wantSuccess,
|
||||
warning: tt.wantWarning,
|
||||
error: tt.wantError,
|
||||
info: tt.wantInfo,
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestShowParseErrors tests the parse error display function.
|
||||
func TestShowParseErrors(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
errors []string
|
||||
wantBold int
|
||||
wantError int
|
||||
wantContains string
|
||||
}{
|
||||
{
|
||||
name: "no parse errors",
|
||||
errors: []string{},
|
||||
wantBold: 0,
|
||||
wantError: 0,
|
||||
wantContains: "",
|
||||
},
|
||||
{
|
||||
name: "single parse error",
|
||||
errors: []string{"Failed to parse action.yml: invalid YAML"},
|
||||
wantBold: 1,
|
||||
wantError: 1,
|
||||
wantContains: "Failed to parse",
|
||||
},
|
||||
{
|
||||
name: "multiple parse errors",
|
||||
errors: []string{
|
||||
"Failed to parse action1.yml: invalid YAML",
|
||||
"Failed to parse action2.yml: file not found",
|
||||
"Failed to parse action3.yml: permission denied",
|
||||
},
|
||||
wantBold: 1,
|
||||
wantError: 3,
|
||||
wantContains: "Failed to parse",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
output := newCapturedOutput()
|
||||
gen := &Generator{Output: output}
|
||||
|
||||
gen.showParseErrors(tt.errors)
|
||||
|
||||
testutil.AssertMessageCounts(t, tt.name, output.CapturedOutput, 0, tt.wantError, 0, tt.wantBold)
|
||||
|
||||
if tt.wantContains != "" && !output.ContainsError(tt.wantContains) {
|
||||
t.Errorf(
|
||||
"showParseErrors() error messages should contain %q, got %v",
|
||||
tt.wantContains,
|
||||
output.ErrorMessages,
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestShowFileIssues tests the file-specific issue display function.
|
||||
func TestShowFileIssues(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
result ValidationResult
|
||||
wantInfo int
|
||||
wantError int
|
||||
wantWarning int
|
||||
wantContains string
|
||||
}{
|
||||
{
|
||||
name: "file with missing fields only",
|
||||
result: ValidationResult{
|
||||
MissingFields: []string{testutil.ValidationTestFile3, "name", "description"},
|
||||
},
|
||||
wantInfo: 1, // File name only (no suggestions)
|
||||
wantError: 2, // 2 missing fields
|
||||
wantWarning: 0,
|
||||
wantContains: "name",
|
||||
},
|
||||
{
|
||||
name: "file with warnings only",
|
||||
result: ValidationResult{
|
||||
MissingFields: []string{testutil.ValidationTestFile3},
|
||||
Warnings: []string{"author field is recommended", "icon field is recommended"},
|
||||
},
|
||||
wantInfo: 1, // File name
|
||||
wantError: 0,
|
||||
wantWarning: 2,
|
||||
wantContains: "author",
|
||||
},
|
||||
{
|
||||
name: "file with missing fields and warnings",
|
||||
result: ValidationResult{
|
||||
MissingFields: []string{testutil.ValidationTestFile3, "name"},
|
||||
Warnings: []string{"author field is recommended"},
|
||||
},
|
||||
wantInfo: 1,
|
||||
wantError: 1,
|
||||
wantWarning: 1,
|
||||
wantContains: "name",
|
||||
},
|
||||
{
|
||||
name: "file with suggestions",
|
||||
result: ValidationResult{
|
||||
MissingFields: []string{testutil.ValidationTestFile3, "name"},
|
||||
Suggestions: []string{"Add a descriptive name field", "See documentation for examples"},
|
||||
},
|
||||
wantInfo: 2, // File name + Suggestions header
|
||||
wantError: 1,
|
||||
wantWarning: 0,
|
||||
wantContains: "descriptive name",
|
||||
},
|
||||
{
|
||||
name: "valid file (no issues)",
|
||||
result: ValidationResult{
|
||||
MissingFields: []string{testutil.ValidationTestFile3},
|
||||
},
|
||||
wantInfo: 1, // Just file name
|
||||
wantError: 0,
|
||||
wantWarning: 0,
|
||||
wantContains: appconstants.ActionFileNameYML,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
output := newCapturedOutput()
|
||||
gen := &Generator{Output: output}
|
||||
|
||||
gen.showFileIssues(tt.result)
|
||||
|
||||
if len(output.InfoMessages) < tt.wantInfo {
|
||||
t.Errorf("showFileIssues() info messages = %d, want at least %d", len(output.InfoMessages), tt.wantInfo)
|
||||
}
|
||||
if len(output.ErrorMessages) != tt.wantError {
|
||||
t.Errorf("showFileIssues() error messages = %d, want %d", len(output.ErrorMessages), tt.wantError)
|
||||
}
|
||||
if len(output.WarningMessages) != tt.wantWarning {
|
||||
t.Errorf("showFileIssues() warning messages = %d, want %d", len(output.WarningMessages), tt.wantWarning)
|
||||
}
|
||||
|
||||
// Check if expected content appears somewhere in the output
|
||||
if tt.wantContains != "" && !output.ContainsMessage(tt.wantContains) {
|
||||
t.Errorf("showFileIssues() output should contain %q, got info=%v, error=%v, warning=%v",
|
||||
tt.wantContains, output.InfoMessages, output.ErrorMessages, output.WarningMessages)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestShowDetailedIssues tests the detailed issues display function.
|
||||
func TestShowDetailedIssues(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
results []ValidationResult
|
||||
totalIssues int
|
||||
verbose bool
|
||||
wantBold int // Expected number of bold messages
|
||||
}{
|
||||
{
|
||||
name: "no issues, not verbose",
|
||||
results: []ValidationResult{
|
||||
{MissingFields: []string{"file: action1.yml"}},
|
||||
{MissingFields: []string{"file: action2.yml"}},
|
||||
},
|
||||
totalIssues: 0,
|
||||
verbose: false,
|
||||
wantBold: 0, // Should not show details
|
||||
},
|
||||
{
|
||||
name: "no issues, verbose mode",
|
||||
results: []ValidationResult{
|
||||
{MissingFields: []string{testutil.ValidationTestFile1}},
|
||||
{MissingFields: []string{testutil.ValidationTestFile2}},
|
||||
},
|
||||
totalIssues: 0,
|
||||
verbose: true,
|
||||
wantBold: 1, // Should show header even with no issues
|
||||
},
|
||||
{
|
||||
name: "some issues",
|
||||
results: []ValidationResult{
|
||||
{MissingFields: []string{testutil.ValidationTestFile1, "name"}},
|
||||
{MissingFields: []string{testutil.ValidationTestFile2}},
|
||||
},
|
||||
totalIssues: 1,
|
||||
verbose: false,
|
||||
wantBold: 1, // Should show details
|
||||
},
|
||||
{
|
||||
name: "files with warnings",
|
||||
results: []ValidationResult{
|
||||
{MissingFields: []string{testutil.ValidationTestFile1}, Warnings: []string{"author recommended"}},
|
||||
},
|
||||
totalIssues: 0,
|
||||
verbose: false,
|
||||
wantBold: 0, // No bold output (warnings don't count as issues, early return)
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
output := newCapturedOutput()
|
||||
gen := &Generator{
|
||||
Output: output,
|
||||
Config: &AppConfig{Verbose: tt.verbose},
|
||||
}
|
||||
|
||||
gen.showDetailedIssues(tt.results, tt.totalIssues)
|
||||
|
||||
if len(output.BoldMessages) != tt.wantBold {
|
||||
t.Errorf("showDetailedIssues() bold messages = %d, want %d", len(output.BoldMessages), tt.wantBold)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestReportValidationResults tests the main validation reporting function.
|
||||
// reportCounts holds the expected counts for validation report output.
|
||||
type reportCounts struct {
|
||||
bold int
|
||||
success bool
|
||||
error bool
|
||||
}
|
||||
|
||||
// validateReportCounts validates that the report output contains expected message counts.
|
||||
func validateReportCounts(
|
||||
t *testing.T,
|
||||
gotBold, gotSuccess, gotError int,
|
||||
want reportCounts,
|
||||
allowUnexpectedErrors bool,
|
||||
) {
|
||||
t.Helper()
|
||||
|
||||
if gotBold < want.bold {
|
||||
t.Errorf("Bold messages = %d, want at least %d", gotBold, want.bold)
|
||||
}
|
||||
|
||||
if want.success && gotSuccess == 0 {
|
||||
t.Error("Expected success messages, got none")
|
||||
}
|
||||
|
||||
if want.error && gotError == 0 {
|
||||
t.Error("Expected error messages, got none")
|
||||
}
|
||||
|
||||
if !allowUnexpectedErrors && gotError > 0 {
|
||||
t.Errorf("Expected no error messages, got %d", gotError)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReportValidationResults(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
results []ValidationResult
|
||||
errors []string
|
||||
wantBold int // Minimum number of bold messages
|
||||
wantSuccess bool
|
||||
wantError bool
|
||||
}{
|
||||
{
|
||||
name: "all valid, no errors",
|
||||
results: []ValidationResult{
|
||||
{MissingFields: []string{testutil.ValidationTestFile1}},
|
||||
{MissingFields: []string{testutil.ValidationTestFile2}},
|
||||
},
|
||||
errors: []string{},
|
||||
wantBold: 1,
|
||||
wantSuccess: true,
|
||||
wantError: false,
|
||||
},
|
||||
{
|
||||
name: "some invalid files",
|
||||
results: []ValidationResult{
|
||||
{MissingFields: []string{testutil.ValidationTestFile1, "name"}},
|
||||
{MissingFields: []string{testutil.ValidationTestFile2}},
|
||||
},
|
||||
errors: []string{},
|
||||
wantBold: 2, // Summary + Details
|
||||
wantSuccess: true,
|
||||
wantError: true,
|
||||
},
|
||||
{
|
||||
name: "parse errors only",
|
||||
results: []ValidationResult{},
|
||||
errors: []string{"Failed to parse action.yml"},
|
||||
wantBold: 2, // Summary + Parse Errors
|
||||
wantSuccess: true,
|
||||
wantError: true,
|
||||
},
|
||||
{
|
||||
name: "mixed validation issues and parse errors",
|
||||
results: []ValidationResult{
|
||||
{MissingFields: []string{testutil.ValidationTestFile1, "name", "description"}},
|
||||
},
|
||||
errors: []string{"Failed to parse action2.yml"},
|
||||
wantBold: 3, // Summary + Details + Parse Errors
|
||||
wantSuccess: true,
|
||||
wantError: true,
|
||||
},
|
||||
{
|
||||
name: "empty results",
|
||||
results: []ValidationResult{},
|
||||
errors: []string{},
|
||||
wantBold: 1,
|
||||
wantSuccess: true,
|
||||
wantError: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
output := newCapturedOutput()
|
||||
gen := &Generator{
|
||||
Output: output,
|
||||
Config: &AppConfig{Verbose: false},
|
||||
}
|
||||
|
||||
gen.reportValidationResults(tt.results, tt.errors)
|
||||
|
||||
counts := reportCounts{
|
||||
bold: tt.wantBold,
|
||||
success: tt.wantSuccess,
|
||||
error: tt.wantError,
|
||||
}
|
||||
validateReportCounts(
|
||||
t,
|
||||
len(output.BoldMessages),
|
||||
len(output.SuccessMessages),
|
||||
len(output.ErrorMessages),
|
||||
counts,
|
||||
tt.wantError,
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
44
internal/generator_validation_test_helper.go
Normal file
44
internal/generator_validation_test_helper.go
Normal file
@@ -0,0 +1,44 @@
|
||||
package internal
|
||||
|
||||
// validationSummaryTestCase defines a test case for validation summary tests.
|
||||
// This helper reduces duplication in test case definitions by providing
|
||||
// a factory function with sensible defaults.
|
||||
type validationSummaryTestCase struct {
|
||||
name string
|
||||
totalFiles int
|
||||
validFiles int
|
||||
totalIssues int
|
||||
resultCount int
|
||||
errorCount int
|
||||
wantBold int
|
||||
wantSuccess int
|
||||
wantWarning int
|
||||
wantError int
|
||||
wantInfo int
|
||||
}
|
||||
|
||||
// validationSummaryParams holds parameters for creating validation summary test cases.
|
||||
type validationSummaryParams struct {
|
||||
name string
|
||||
totalFiles, validFiles, totalIssues, resultCount, errorCount int
|
||||
wantWarning, wantError, wantInfo int
|
||||
}
|
||||
|
||||
// createValidationSummaryTest creates a validation summary test case with defaults.
|
||||
// Default values: wantBold=1, wantSuccess=1, wantWarning=0, wantError=0, wantInfo=0
|
||||
// Only provide the fields that differ from defaults.
|
||||
func createValidationSummaryTest(params validationSummaryParams) validationSummaryTestCase {
|
||||
return validationSummaryTestCase{
|
||||
name: params.name,
|
||||
totalFiles: params.totalFiles,
|
||||
validFiles: params.validFiles,
|
||||
totalIssues: params.totalIssues,
|
||||
resultCount: params.resultCount,
|
||||
errorCount: params.errorCount,
|
||||
wantBold: 1, // Always 1
|
||||
wantSuccess: 1, // Always 1
|
||||
wantWarning: params.wantWarning,
|
||||
wantError: params.wantError,
|
||||
wantInfo: params.wantInfo,
|
||||
}
|
||||
}
|
||||
@@ -155,7 +155,11 @@ func getRemoteURLFromConfig(repoRoot string) (string, error) {
|
||||
|
||||
// getDefaultBranch gets the default branch name.
|
||||
func getDefaultBranch(repoRoot string) string {
|
||||
cmd := exec.Command("git", "symbolic-ref", "refs/remotes/origin/HEAD")
|
||||
cmd := exec.Command(
|
||||
appconstants.GitCommand,
|
||||
"symbolic-ref",
|
||||
"refs/remotes/origin/HEAD",
|
||||
) // #nosec G204 -- controlled git command
|
||||
cmd.Dir = repoRoot
|
||||
|
||||
output, err := cmd.Output()
|
||||
@@ -209,7 +213,7 @@ func parseGitHubURL(url string) (organization, repository string) {
|
||||
repo := matches[2]
|
||||
|
||||
// Remove .git suffix if present
|
||||
repo = strings.TrimSuffix(repo, ".git")
|
||||
repo = strings.TrimSuffix(repo, appconstants.DirGit)
|
||||
|
||||
return org, repo
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package git
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
@@ -22,18 +21,11 @@ func TestFindRepositoryRoot(t *testing.T) {
|
||||
setupFunc: func(t *testing.T, tmpDir string) string {
|
||||
t.Helper()
|
||||
// Create .git directory
|
||||
gitDir := filepath.Join(tmpDir, ".git")
|
||||
err := os.MkdirAll(gitDir, 0750) // #nosec G301 -- test directory permissions
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create .git directory: %v", err)
|
||||
}
|
||||
testutil.SetupGitDirectory(t, tmpDir)
|
||||
|
||||
// Create subdirectory to test from
|
||||
subDir := filepath.Join(tmpDir, "subdir", "nested")
|
||||
err = os.MkdirAll(subDir, 0750) // #nosec G301 -- test directory permissions
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create subdirectory: %v", err)
|
||||
}
|
||||
testutil.CreateTestDir(t, subDir)
|
||||
|
||||
return subDir
|
||||
},
|
||||
@@ -54,22 +46,19 @@ func TestFindRepositoryRoot(t *testing.T) {
|
||||
expectEmpty: false,
|
||||
},
|
||||
{
|
||||
name: "no git repository",
|
||||
name: testutil.TestCaseNameNoGitRepository,
|
||||
setupFunc: func(t *testing.T, tmpDir string) string {
|
||||
t.Helper()
|
||||
// Create subdirectory without .git
|
||||
subDir := filepath.Join(tmpDir, "subdir")
|
||||
err := os.MkdirAll(subDir, 0750) // #nosec G301 -- test directory permissions
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create subdirectory: %v", err)
|
||||
}
|
||||
testutil.CreateTestDir(t, subDir)
|
||||
|
||||
return subDir
|
||||
},
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
name: "nonexistent directory",
|
||||
name: testutil.TestCaseNameNonexistentDir,
|
||||
setupFunc: func(_ *testing.T, tmpDir string) string {
|
||||
t.Helper()
|
||||
|
||||
@@ -123,19 +112,9 @@ func TestDetectGitRepository(t *testing.T) {
|
||||
setupFunc func(t *testing.T, tmpDir string) string
|
||||
checkFunc func(t *testing.T, info *RepoInfo)
|
||||
}{
|
||||
{
|
||||
createGitRepoTestCase(gitTestCase{
|
||||
name: "GitHub repository",
|
||||
setupFunc: func(t *testing.T, tmpDir string) string {
|
||||
t.Helper()
|
||||
// Create .git directory
|
||||
gitDir := filepath.Join(tmpDir, ".git")
|
||||
err := os.MkdirAll(gitDir, 0750) // #nosec G301 -- test directory permissions
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create .git directory: %v", err)
|
||||
}
|
||||
|
||||
// Create config file with GitHub remote
|
||||
configContent := `[core]
|
||||
configContent: `[core]
|
||||
repositoryformatversion = 0
|
||||
filemode = true
|
||||
bare = false
|
||||
@@ -146,47 +125,23 @@ func TestDetectGitRepository(t *testing.T) {
|
||||
[branch "main"]
|
||||
remote = origin
|
||||
merge = refs/heads/main
|
||||
`
|
||||
configPath := filepath.Join(gitDir, "config")
|
||||
testutil.WriteTestFile(t, configPath, configContent)
|
||||
|
||||
return tmpDir
|
||||
},
|
||||
checkFunc: func(t *testing.T, info *RepoInfo) {
|
||||
t.Helper()
|
||||
testutil.AssertEqual(t, "owner", info.Organization)
|
||||
testutil.AssertEqual(t, "repo", info.Repository)
|
||||
testutil.AssertEqual(t, "https://github.com/owner/repo.git", info.RemoteURL)
|
||||
},
|
||||
},
|
||||
{
|
||||
`,
|
||||
expectedOrg: "owner",
|
||||
expectedRepo: "repo",
|
||||
expectedURL: "https://github.com/owner/repo.git",
|
||||
}),
|
||||
createGitRepoTestCase(gitTestCase{
|
||||
name: "SSH remote URL",
|
||||
setupFunc: func(t *testing.T, tmpDir string) string {
|
||||
t.Helper()
|
||||
gitDir := filepath.Join(tmpDir, ".git")
|
||||
err := os.MkdirAll(gitDir, 0750) // #nosec G301 -- test directory permissions
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create .git directory: %v", err)
|
||||
}
|
||||
|
||||
configContent := `[remote "origin"]
|
||||
configContent: `[remote "origin"]
|
||||
url = git@github.com:owner/repo.git
|
||||
fetch = +refs/heads/*:refs/remotes/origin/*
|
||||
`
|
||||
configPath := filepath.Join(gitDir, "config")
|
||||
testutil.WriteTestFile(t, configPath, configContent)
|
||||
|
||||
return tmpDir
|
||||
},
|
||||
checkFunc: func(t *testing.T, info *RepoInfo) {
|
||||
t.Helper()
|
||||
testutil.AssertEqual(t, "owner", info.Organization)
|
||||
testutil.AssertEqual(t, "repo", info.Repository)
|
||||
testutil.AssertEqual(t, "git@github.com:owner/repo.git", info.RemoteURL)
|
||||
},
|
||||
},
|
||||
`,
|
||||
expectedOrg: "owner",
|
||||
expectedRepo: "repo",
|
||||
expectedURL: "git@github.com:owner/repo.git",
|
||||
}),
|
||||
{
|
||||
name: "no git repository",
|
||||
name: testutil.TestCaseNameNoGitRepository,
|
||||
setupFunc: func(_ *testing.T, tmpDir string) string {
|
||||
return tmpDir
|
||||
},
|
||||
@@ -197,33 +152,16 @@ func TestDetectGitRepository(t *testing.T) {
|
||||
testutil.AssertEqual(t, "", info.Repository)
|
||||
},
|
||||
},
|
||||
{
|
||||
createGitRepoTestCase(gitTestCase{
|
||||
name: "git repository without origin remote",
|
||||
setupFunc: func(t *testing.T, tmpDir string) string {
|
||||
t.Helper()
|
||||
gitDir := filepath.Join(tmpDir, ".git")
|
||||
err := os.MkdirAll(gitDir, 0750) // #nosec G301 -- test directory permissions
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create .git directory: %v", err)
|
||||
}
|
||||
|
||||
configContent := `[core]
|
||||
configContent: `[core]
|
||||
repositoryformatversion = 0
|
||||
filemode = true
|
||||
bare = false
|
||||
`
|
||||
configPath := filepath.Join(gitDir, "config")
|
||||
testutil.WriteTestFile(t, configPath, configContent)
|
||||
|
||||
return tmpDir
|
||||
},
|
||||
checkFunc: func(t *testing.T, info *RepoInfo) {
|
||||
t.Helper()
|
||||
testutil.AssertEqual(t, true, info.IsGitRepo)
|
||||
testutil.AssertEqual(t, "", info.Organization)
|
||||
testutil.AssertEqual(t, "", info.Repository)
|
||||
},
|
||||
},
|
||||
`,
|
||||
expectedOrg: "",
|
||||
expectedRepo: "",
|
||||
}),
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
@@ -261,7 +199,7 @@ func TestParseGitHubURL(t *testing.T) {
|
||||
expectedRepo: "repo",
|
||||
},
|
||||
{
|
||||
name: "SSH GitHub URL",
|
||||
name: testutil.TestCaseNameSSHGitHub,
|
||||
remoteURL: "git@github.com:owner/repo.git",
|
||||
expectedOrg: "owner",
|
||||
expectedRepo: "repo",
|
||||
@@ -298,7 +236,7 @@ func TestParseGitHubURL(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRepoInfo_GetRepositoryName(t *testing.T) {
|
||||
func TestRepoInfoGetRepositoryName(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
@@ -344,3 +282,532 @@ func TestRepoInfo_GetRepositoryName(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestRepoInfoGenerateUsesStatement tests the GenerateUsesStatement method.
|
||||
func TestRepoInfoGenerateUsesStatement(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
repoInfo *RepoInfo
|
||||
actionName string
|
||||
version string
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "repository-level action",
|
||||
repoInfo: &RepoInfo{
|
||||
Organization: "actions",
|
||||
Repository: "checkout",
|
||||
},
|
||||
actionName: "",
|
||||
version: "v3",
|
||||
expected: testutil.TestActionCheckoutV3,
|
||||
},
|
||||
{
|
||||
name: "repository-level action with same name",
|
||||
repoInfo: &RepoInfo{
|
||||
Organization: "actions",
|
||||
Repository: "checkout",
|
||||
},
|
||||
actionName: "checkout",
|
||||
version: "v3",
|
||||
expected: testutil.TestActionCheckoutV3,
|
||||
},
|
||||
{
|
||||
name: testutil.TestCaseNameSubdirAction,
|
||||
repoInfo: &RepoInfo{
|
||||
Organization: "actions",
|
||||
Repository: "toolkit",
|
||||
},
|
||||
actionName: "cache",
|
||||
version: "v2",
|
||||
expected: "actions/toolkit/cache@v2",
|
||||
},
|
||||
{
|
||||
name: "without organization",
|
||||
repoInfo: &RepoInfo{
|
||||
Organization: "",
|
||||
Repository: "",
|
||||
},
|
||||
actionName: "my-action",
|
||||
version: "v1",
|
||||
expected: "your-org/my-action@v1",
|
||||
},
|
||||
{
|
||||
name: "without organization and action name",
|
||||
repoInfo: &RepoInfo{
|
||||
Organization: "",
|
||||
Repository: "",
|
||||
},
|
||||
actionName: "",
|
||||
version: "v1",
|
||||
expected: "your-org/your-action@v1",
|
||||
},
|
||||
{
|
||||
name: "with SHA version",
|
||||
repoInfo: &RepoInfo{
|
||||
Organization: "actions",
|
||||
Repository: "checkout",
|
||||
},
|
||||
actionName: "",
|
||||
version: "abc123def456",
|
||||
expected: "actions/checkout@abc123def456",
|
||||
},
|
||||
{
|
||||
name: "with main branch",
|
||||
repoInfo: &RepoInfo{
|
||||
Organization: "actions",
|
||||
Repository: "setup-node",
|
||||
},
|
||||
actionName: "",
|
||||
version: "main",
|
||||
expected: "actions/setup-node@main",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
result := tt.repoInfo.GenerateUsesStatement(tt.actionName, tt.version)
|
||||
testutil.AssertEqual(t, tt.expected, result)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetDefaultBranch_Fallbacks tests branch detection fallback logic.
|
||||
func TestGetDefaultBranchFallbacks(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
setupFunc func(t *testing.T, tmpDir string) string
|
||||
expectedBranch string
|
||||
}{
|
||||
createDefaultBranchTestCase(defaultBranchTestCase{
|
||||
name: "git config with main branch",
|
||||
branch: "main",
|
||||
expectedBranch: "main",
|
||||
}),
|
||||
createDefaultBranchTestCase(defaultBranchTestCase{
|
||||
name: "git config with master branch - returns main fallback",
|
||||
branch: "master",
|
||||
expectedBranch: "main",
|
||||
}),
|
||||
createDefaultBranchTestCase(defaultBranchTestCase{
|
||||
name: "git config with develop branch - returns main fallback",
|
||||
branch: "develop",
|
||||
expectedBranch: "main",
|
||||
}),
|
||||
{
|
||||
name: "no git config - returns main fallback",
|
||||
setupFunc: func(t *testing.T, tmpDir string) string {
|
||||
t.Helper()
|
||||
_ = testutil.SetupGitDirectory(t, tmpDir)
|
||||
|
||||
return tmpDir
|
||||
},
|
||||
expectedBranch: "main", // Falls back to "main" when git command fails
|
||||
},
|
||||
{
|
||||
name: "malformed git config - returns main fallback",
|
||||
setupFunc: func(t *testing.T, tmpDir string) string {
|
||||
t.Helper()
|
||||
gitDir := testutil.SetupGitDirectory(t, tmpDir)
|
||||
|
||||
configContent := `[branch this is malformed`
|
||||
configPath := filepath.Join(gitDir, "config")
|
||||
testutil.WriteTestFile(t, configPath, configContent)
|
||||
|
||||
return tmpDir
|
||||
},
|
||||
expectedBranch: "main", // Falls back to "main" when git command fails
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tmpDir, cleanup := testutil.TempDir(t)
|
||||
defer cleanup()
|
||||
|
||||
repoDir := tt.setupFunc(t, tmpDir)
|
||||
branch := getDefaultBranch(repoDir)
|
||||
|
||||
testutil.AssertEqual(t, tt.expectedBranch, branch)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetRemoteURL_AllSources tests all remote URL detection methods.
|
||||
func TestGetRemoteURLAllSources(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
setupFunc func(t *testing.T, tmpDir string) string
|
||||
expectError bool
|
||||
expectedURL string
|
||||
}{
|
||||
createGitURLTestCase(gitURLTestCase{
|
||||
name: "remote from git config - https",
|
||||
configContent: `[remote "origin"]
|
||||
url = https://github.com/test/repo.git
|
||||
`,
|
||||
expectError: false,
|
||||
expectedURL: "https://github.com/test/repo.git",
|
||||
}),
|
||||
createGitURLTestCase(gitURLTestCase{
|
||||
name: "remote from git config - ssh",
|
||||
configContent: `[remote "origin"]
|
||||
url = git@github.com:user/repo.git
|
||||
`,
|
||||
expectError: false,
|
||||
expectedURL: "git@github.com:user/repo.git",
|
||||
}),
|
||||
createGitURLTestCase(gitURLTestCase{
|
||||
name: "multiple remotes - origin takes precedence",
|
||||
configContent: `[remote "upstream"]
|
||||
url = https://github.com/upstream/repo
|
||||
[remote "origin"]
|
||||
url = https://github.com/origin/repo
|
||||
`,
|
||||
expectError: false,
|
||||
expectedURL: "https://github.com/origin/repo",
|
||||
}),
|
||||
{
|
||||
name: "no remote configured",
|
||||
setupFunc: func(t *testing.T, tmpDir string) string {
|
||||
t.Helper()
|
||||
_ = testutil.SetupGitDirectory(t, tmpDir)
|
||||
|
||||
return tmpDir
|
||||
},
|
||||
expectError: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tmpDir, cleanup := testutil.TempDir(t)
|
||||
defer cleanup()
|
||||
|
||||
repoDir := tt.setupFunc(t, tmpDir)
|
||||
url, err := getRemoteURL(repoDir)
|
||||
|
||||
if tt.expectError {
|
||||
testutil.AssertError(t, err)
|
||||
} else {
|
||||
testutil.AssertNoError(t, err)
|
||||
testutil.AssertEqual(t, tt.expectedURL, url)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetRemoteURLFromConfig_EdgeCases tests git config parsing with edge cases.
|
||||
func TestGetRemoteURLFromConfigEdgeCases(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
configContent string
|
||||
expectError bool
|
||||
expectedURL string
|
||||
description string
|
||||
}{
|
||||
{
|
||||
name: "standard git config",
|
||||
configContent: `[remote "origin"]
|
||||
url = ` + testutil.TestURLGitHubUserRepo + `
|
||||
`,
|
||||
expectError: false,
|
||||
expectedURL: testutil.TestURLGitHubUserRepo,
|
||||
description: "Standard git config",
|
||||
},
|
||||
{
|
||||
name: "config with comments",
|
||||
configContent: `# This is a comment
|
||||
[remote "origin"]
|
||||
# Another comment
|
||||
url = ` + testutil.TestURLGitHubUserRepo + `
|
||||
fetch = +refs/heads/*:refs/remotes/origin/*
|
||||
`,
|
||||
expectError: false,
|
||||
expectedURL: testutil.TestURLGitHubUserRepo,
|
||||
description: "Config with comments should be parsed",
|
||||
},
|
||||
{
|
||||
name: "empty config",
|
||||
configContent: ``,
|
||||
expectError: true,
|
||||
description: "Empty config",
|
||||
},
|
||||
{
|
||||
name: "incomplete section",
|
||||
configContent: `[remote "origin"
|
||||
url = ` + testutil.TestURLGitHubUserRepo + `
|
||||
`,
|
||||
expectError: true,
|
||||
description: "Malformed section",
|
||||
},
|
||||
{
|
||||
name: "url with spaces",
|
||||
configContent: `[remote "origin"]
|
||||
url = https://github.com/user name/repo name
|
||||
`,
|
||||
expectError: false,
|
||||
expectedURL: "https://github.com/user name/repo name",
|
||||
description: "URL with spaces should be preserved",
|
||||
},
|
||||
{
|
||||
name: "multiple origin sections - first wins",
|
||||
configContent: `[remote "origin"]
|
||||
url = https://github.com/first/repo
|
||||
[remote "origin"]
|
||||
url = https://github.com/second/repo
|
||||
`,
|
||||
expectError: false,
|
||||
expectedURL: "https://github.com/first/repo",
|
||||
description: "First origin section takes precedence",
|
||||
},
|
||||
{
|
||||
name: "ssh url format",
|
||||
configContent: `[remote "origin"]
|
||||
url = git@gitlab.com:user/repo.git
|
||||
`,
|
||||
expectError: false,
|
||||
expectedURL: "git@gitlab.com:user/repo.git",
|
||||
description: "SSH URL format",
|
||||
},
|
||||
{
|
||||
name: "url with trailing whitespace",
|
||||
configContent: `[remote "origin"]
|
||||
url = ` + testutil.TestURLGitHubUserRepo + `
|
||||
`,
|
||||
expectError: false,
|
||||
expectedURL: testutil.TestURLGitHubUserRepo,
|
||||
description: "Trailing whitespace should be trimmed",
|
||||
},
|
||||
{
|
||||
name: "config without url field",
|
||||
configContent: `[remote "origin"]
|
||||
fetch = +refs/heads/*:refs/remotes/origin/*
|
||||
`,
|
||||
expectError: true,
|
||||
description: "Remote without URL",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tmpDir, cleanup := testutil.TempDir(t)
|
||||
defer cleanup()
|
||||
|
||||
gitDir := testutil.SetupGitDirectory(t, tmpDir)
|
||||
|
||||
if tt.configContent != "" {
|
||||
configPath := filepath.Join(gitDir, "config")
|
||||
testutil.WriteTestFile(t, configPath, tt.configContent)
|
||||
}
|
||||
|
||||
url, err := getRemoteURLFromConfig(tmpDir)
|
||||
|
||||
if tt.expectError {
|
||||
testutil.AssertError(t, err)
|
||||
} else {
|
||||
testutil.AssertNoError(t, err)
|
||||
testutil.AssertEqual(t, tt.expectedURL, url)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestFindRepositoryRoot_EdgeCases tests additional edge cases for repository root detection.
|
||||
func TestFindRepositoryRootEdgeCases(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
setupFunc func(t *testing.T, tmpDir string) string
|
||||
expectError bool
|
||||
checkFunc func(t *testing.T, tmpDir, repoRoot string)
|
||||
}{
|
||||
{
|
||||
name: "deeply nested subdirectory",
|
||||
setupFunc: func(t *testing.T, tmpDir string) string {
|
||||
t.Helper()
|
||||
testutil.SetupGitDirectory(t, tmpDir)
|
||||
|
||||
deepPath := filepath.Join(tmpDir, "a", "b", "c", "d", "e")
|
||||
testutil.CreateTestDir(t, deepPath)
|
||||
|
||||
return deepPath
|
||||
},
|
||||
expectError: false,
|
||||
checkFunc: func(t *testing.T, tmpDir, repoRoot string) {
|
||||
t.Helper()
|
||||
testutil.AssertEqual(t, tmpDir, repoRoot)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "git worktree with .git file",
|
||||
setupFunc: func(t *testing.T, tmpDir string) string {
|
||||
t.Helper()
|
||||
gitFile := filepath.Join(tmpDir, ".git")
|
||||
testutil.WriteTestFile(t, gitFile, "gitdir: /path/to/worktree")
|
||||
|
||||
return tmpDir
|
||||
},
|
||||
expectError: false,
|
||||
checkFunc: func(t *testing.T, tmpDir, repoRoot string) {
|
||||
t.Helper()
|
||||
testutil.AssertEqual(t, tmpDir, repoRoot)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "current directory is repo root",
|
||||
setupFunc: func(t *testing.T, tmpDir string) string {
|
||||
t.Helper()
|
||||
testutil.SetupGitDirectory(t, tmpDir)
|
||||
|
||||
return tmpDir
|
||||
},
|
||||
expectError: false,
|
||||
checkFunc: func(t *testing.T, tmpDir, repoRoot string) {
|
||||
t.Helper()
|
||||
testutil.AssertEqual(t, tmpDir, repoRoot)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "path with spaces",
|
||||
setupFunc: func(t *testing.T, tmpDir string) string {
|
||||
t.Helper()
|
||||
testutil.SetupGitDirectory(t, tmpDir)
|
||||
|
||||
spacePath := filepath.Join(tmpDir, "path with spaces")
|
||||
testutil.CreateTestDir(t, spacePath)
|
||||
|
||||
return spacePath
|
||||
},
|
||||
expectError: false,
|
||||
checkFunc: func(t *testing.T, tmpDir, repoRoot string) {
|
||||
t.Helper()
|
||||
testutil.AssertEqual(t, tmpDir, repoRoot)
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tmpDir, cleanup := testutil.TempDir(t)
|
||||
defer cleanup()
|
||||
|
||||
testDir := tt.setupFunc(t, tmpDir)
|
||||
repoRoot, err := FindRepositoryRoot(testDir)
|
||||
|
||||
if tt.expectError {
|
||||
testutil.AssertError(t, err)
|
||||
} else {
|
||||
testutil.AssertNoError(t, err)
|
||||
tt.checkFunc(t, tmpDir, repoRoot)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestParseGitHubURL_EdgeCases tests additional URL parsing edge cases.
|
||||
func TestParseGitHubURLEdgeCases(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
remoteURL string
|
||||
expectedOrg string
|
||||
expectedRepo string
|
||||
description string
|
||||
}{
|
||||
{
|
||||
name: "gitlab https url",
|
||||
remoteURL: "https://gitlab.com/owner/repo.git",
|
||||
expectedOrg: "",
|
||||
expectedRepo: "",
|
||||
description: "Non-GitHub URLs return empty",
|
||||
},
|
||||
{
|
||||
name: "github url with subgroups",
|
||||
remoteURL: "https://github.com/org/subgroup/repo.git",
|
||||
expectedOrg: "org",
|
||||
expectedRepo: "subgroup", // Regex only captures first two path segments
|
||||
description: "GitHub URLs with subpaths only capture org/subgroup",
|
||||
},
|
||||
{
|
||||
name: "ssh url without git suffix",
|
||||
remoteURL: "git@github.com:owner/repo",
|
||||
expectedOrg: "owner",
|
||||
expectedRepo: "repo",
|
||||
description: "SSH URL without .git suffix",
|
||||
},
|
||||
{
|
||||
name: "url with trailing slash",
|
||||
remoteURL: "https://github.com/owner/repo/",
|
||||
expectedOrg: "owner",
|
||||
expectedRepo: "repo",
|
||||
description: "Handles trailing slash",
|
||||
},
|
||||
{
|
||||
name: "url with query parameters",
|
||||
remoteURL: "https://github.com/owner/repo?param=value",
|
||||
expectedOrg: "owner",
|
||||
expectedRepo: "repo?param=value", // Regex doesn't strip query params
|
||||
description: "Query parameters are not stripped by regex",
|
||||
},
|
||||
{
|
||||
name: "malformed ssh url",
|
||||
remoteURL: "git@github.com/owner/repo.git",
|
||||
expectedOrg: "owner",
|
||||
expectedRepo: "repo", // Actually matches the pattern
|
||||
description: "Malformed SSH URL still matches pattern",
|
||||
},
|
||||
{
|
||||
name: "url with username",
|
||||
remoteURL: "https://user@github.com/owner/repo.git",
|
||||
expectedOrg: "owner",
|
||||
expectedRepo: "repo",
|
||||
description: "Handles URL with username",
|
||||
},
|
||||
{
|
||||
name: "github enterprise url",
|
||||
remoteURL: "https://github.company.com/owner/repo.git",
|
||||
expectedOrg: "",
|
||||
expectedRepo: "",
|
||||
description: "GitHub Enterprise URLs return empty (not github.com)",
|
||||
},
|
||||
{
|
||||
name: "short ssh format",
|
||||
remoteURL: "github.com:owner/repo.git",
|
||||
expectedOrg: "owner",
|
||||
expectedRepo: "repo", // Actually matches the pattern with ':'
|
||||
description: "Short SSH format matches the regex pattern",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
org, repo := parseGitHubURL(tt.remoteURL)
|
||||
|
||||
testutil.AssertEqual(t, tt.expectedOrg, org)
|
||||
testutil.AssertEqual(t, tt.expectedRepo, repo)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
126
internal/git/detector_test_helper.go
Normal file
126
internal/git/detector_test_helper.go
Normal file
@@ -0,0 +1,126 @@
|
||||
package git
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/ivuorinen/gh-action-readme/testutil"
|
||||
)
|
||||
|
||||
// gitTestCase defines the configuration for a git repository test case.
|
||||
type gitTestCase struct {
|
||||
name string
|
||||
configContent string
|
||||
expectedOrg string
|
||||
expectedRepo string
|
||||
expectedBranch string
|
||||
expectedURL string
|
||||
}
|
||||
|
||||
// createGitRepoTestCase creates a test table entry for git repository detection tests.
|
||||
// setupGitTestRepo creates a test git directory with the specified config content.
|
||||
// This helper is used by multiple test case creators to eliminate duplicate setup logic.
|
||||
func setupGitTestRepo(t *testing.T, tmpDir, configContent string) string {
|
||||
t.Helper()
|
||||
gitDir := testutil.SetupGitDirectory(t, tmpDir)
|
||||
configPath := filepath.Join(gitDir, "config")
|
||||
testutil.WriteTestFile(t, configPath, configContent)
|
||||
|
||||
return tmpDir
|
||||
}
|
||||
|
||||
// This helper reduces duplication by standardizing the setup and assertion patterns
|
||||
// for git repository test cases.
|
||||
func createGitRepoTestCase(tc gitTestCase) struct {
|
||||
name string
|
||||
setupFunc func(t *testing.T, tmpDir string) string
|
||||
checkFunc func(t *testing.T, info *RepoInfo)
|
||||
} {
|
||||
return struct {
|
||||
name string
|
||||
setupFunc func(t *testing.T, tmpDir string) string
|
||||
checkFunc func(t *testing.T, info *RepoInfo)
|
||||
}{
|
||||
name: tc.name,
|
||||
setupFunc: func(t *testing.T, tmpDir string) string {
|
||||
t.Helper()
|
||||
|
||||
return setupGitTestRepo(t, tmpDir, tc.configContent)
|
||||
},
|
||||
checkFunc: func(t *testing.T, info *RepoInfo) {
|
||||
t.Helper()
|
||||
testutil.AssertEqual(t, tc.expectedOrg, info.Organization)
|
||||
testutil.AssertEqual(t, tc.expectedRepo, info.Repository)
|
||||
if tc.expectedBranch != "" {
|
||||
testutil.AssertEqual(t, tc.expectedBranch, info.DefaultBranch)
|
||||
}
|
||||
if tc.expectedURL != "" {
|
||||
testutil.AssertEqual(t, tc.expectedURL, info.RemoteURL)
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// gitURLTestCase defines the configuration for git remote URL test cases.
|
||||
type gitURLTestCase struct {
|
||||
name string
|
||||
configContent string
|
||||
expectError bool
|
||||
expectedURL string
|
||||
}
|
||||
|
||||
// createGitURLTestCase creates a test table entry for git remote URL detection tests.
|
||||
// This helper reduces duplication by standardizing the setup pattern for URL tests.
|
||||
func createGitURLTestCase(tc gitURLTestCase) struct {
|
||||
name string
|
||||
setupFunc func(t *testing.T, tmpDir string) string
|
||||
expectError bool
|
||||
expectedURL string
|
||||
} {
|
||||
return struct {
|
||||
name string
|
||||
setupFunc func(t *testing.T, tmpDir string) string
|
||||
expectError bool
|
||||
expectedURL string
|
||||
}{
|
||||
name: tc.name,
|
||||
setupFunc: func(t *testing.T, tmpDir string) string {
|
||||
t.Helper()
|
||||
|
||||
return setupGitTestRepo(t, tmpDir, tc.configContent)
|
||||
},
|
||||
expectError: tc.expectError,
|
||||
expectedURL: tc.expectedURL,
|
||||
}
|
||||
}
|
||||
|
||||
// defaultBranchTestCase defines the configuration for default branch detection tests.
|
||||
type defaultBranchTestCase struct {
|
||||
name string
|
||||
branch string
|
||||
expectedBranch string
|
||||
}
|
||||
|
||||
// createDefaultBranchTestCase creates a test table entry for default branch tests.
|
||||
// This helper reduces duplication for tests that set up git repos with different branches.
|
||||
func createDefaultBranchTestCase(tc defaultBranchTestCase) struct {
|
||||
name string
|
||||
setupFunc func(t *testing.T, tmpDir string) string
|
||||
expectedBranch string
|
||||
} {
|
||||
return struct {
|
||||
name string
|
||||
setupFunc func(t *testing.T, tmpDir string) string
|
||||
expectedBranch string
|
||||
}{
|
||||
name: tc.name,
|
||||
setupFunc: func(t *testing.T, tmpDir string) string {
|
||||
t.Helper()
|
||||
gitDir := testutil.SetupGitDirectory(t, tmpDir)
|
||||
testutil.CreateGitConfigWithRemote(t, gitDir, testutil.TestURLGitHubUserRepo, tc.branch)
|
||||
|
||||
return tmpDir
|
||||
},
|
||||
expectedBranch: tc.expectedBranch,
|
||||
}
|
||||
}
|
||||
@@ -108,7 +108,7 @@ func TestCreateAnalyzerOrExit(t *testing.T) {
|
||||
// In a real-world scenario, we might refactor to return errors instead
|
||||
}
|
||||
|
||||
func TestCreateAnalyzer_Integration(t *testing.T) {
|
||||
func TestCreateAnalyzerIntegration(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Test integration with actual generator functionality
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -117,14 +116,11 @@ func TestFindGitRepoRoot(t *testing.T) {
|
||||
setupFunc: func(t *testing.T, tmpDir string) string {
|
||||
t.Helper()
|
||||
// Create .git directory
|
||||
gitDir := filepath.Join(tmpDir, ".git")
|
||||
err := os.MkdirAll(gitDir, 0750) // #nosec G301 -- test directory permissions
|
||||
testutil.AssertNoError(t, err)
|
||||
_ = testutil.SetupGitDirectory(t, tmpDir)
|
||||
|
||||
// Create subdirectory to test from
|
||||
subDir := filepath.Join(tmpDir, "subdir")
|
||||
err = os.MkdirAll(subDir, 0750) // #nosec G301 -- test directory permissions
|
||||
testutil.AssertNoError(t, err)
|
||||
testutil.CreateTestDir(t, subDir)
|
||||
|
||||
return subDir
|
||||
},
|
||||
@@ -143,14 +139,11 @@ func TestFindGitRepoRoot(t *testing.T) {
|
||||
setupFunc: func(t *testing.T, tmpDir string) string {
|
||||
t.Helper()
|
||||
// Create .git directory at root
|
||||
gitDir := filepath.Join(tmpDir, ".git")
|
||||
err := os.MkdirAll(gitDir, 0750) // #nosec G301 -- test directory permissions
|
||||
testutil.AssertNoError(t, err)
|
||||
_ = testutil.SetupGitDirectory(t, tmpDir)
|
||||
|
||||
// Create deeply nested subdirectory
|
||||
nestedDir := filepath.Join(tmpDir, "a", "b", "c")
|
||||
err = os.MkdirAll(nestedDir, 0750) // #nosec G301 -- test directory permissions
|
||||
testutil.AssertNoError(t, err)
|
||||
testutil.CreateTestDir(t, nestedDir)
|
||||
|
||||
return nestedDir
|
||||
},
|
||||
@@ -241,9 +234,7 @@ func TestGetGitRepoRootAndInfo(t *testing.T) {
|
||||
func setupCompleteGitRepo(t *testing.T, tmpDir string) string {
|
||||
t.Helper()
|
||||
// Create .git directory
|
||||
gitDir := filepath.Join(tmpDir, ".git")
|
||||
err := os.MkdirAll(gitDir, 0750) // #nosec G301 -- test directory permissions
|
||||
testutil.AssertNoError(t, err)
|
||||
gitDir := testutil.SetupGitDirectory(t, tmpDir)
|
||||
|
||||
// Create a basic git config to make it look like a real repo
|
||||
configContent := `[core]
|
||||
@@ -258,8 +249,7 @@ func setupCompleteGitRepo(t *testing.T, tmpDir string) string {
|
||||
merge = refs/heads/main
|
||||
`
|
||||
configPath := filepath.Join(gitDir, "config")
|
||||
err = os.WriteFile(configPath, []byte(configContent), 0600) // #nosec G306 -- test file permissions
|
||||
testutil.AssertNoError(t, err)
|
||||
testutil.WriteTestFile(t, configPath, configContent)
|
||||
|
||||
return tmpDir
|
||||
}
|
||||
@@ -267,9 +257,7 @@ func setupCompleteGitRepo(t *testing.T, tmpDir string) string {
|
||||
func setupMinimalGitRepo(t *testing.T, tmpDir string) string {
|
||||
t.Helper()
|
||||
// Create .git directory but with minimal content
|
||||
gitDir := filepath.Join(tmpDir, ".git")
|
||||
err := os.MkdirAll(gitDir, 0750) // #nosec G301 -- test directory permissions
|
||||
testutil.AssertNoError(t, err)
|
||||
_ = testutil.SetupGitDirectory(t, tmpDir)
|
||||
|
||||
return tmpDir
|
||||
}
|
||||
@@ -282,10 +270,10 @@ func verifyRepoRoot(t *testing.T, repoRoot, tmpDir string) {
|
||||
}
|
||||
|
||||
// Test error handling in GetGitRepoRootAndInfo.
|
||||
func TestGetGitRepoRootAndInfo_ErrorHandling(t *testing.T) {
|
||||
func TestGetGitRepoRootAndInfoErrorHandling(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("nonexistent directory", func(t *testing.T) {
|
||||
t.Run(testutil.TestCaseNameNonexistentDir, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
nonexistentPath := "/this/path/should/not/exist"
|
||||
|
||||
@@ -10,7 +10,7 @@ type HTMLWriter struct {
|
||||
Footer string
|
||||
}
|
||||
|
||||
func (w *HTMLWriter) Write(output string, path string) error {
|
||||
func (w *HTMLWriter) Write(output, path string) error {
|
||||
f, err := os.Create(path) // #nosec G304 -- path from function parameter
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
318
internal/html_test.go
Normal file
318
internal/html_test.go
Normal file
@@ -0,0 +1,318 @@
|
||||
package internal
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/ivuorinen/gh-action-readme/testutil"
|
||||
)
|
||||
|
||||
// mustSafePath validates that a path is safe (no "..", matches cleaned version).
|
||||
// Fails the test if path is unsafe.
|
||||
func mustSafePath(t *testing.T, p string) string {
|
||||
t.Helper()
|
||||
cleaned := filepath.Clean(p)
|
||||
if cleaned != p {
|
||||
t.Fatalf("path %q does not match cleaned path %q", p, cleaned)
|
||||
}
|
||||
if strings.Contains(cleaned, "..") {
|
||||
t.Fatalf("path %q contains unsafe .. component", p)
|
||||
}
|
||||
|
||||
return cleaned
|
||||
}
|
||||
|
||||
// TestHTMLWriterWrite tests the HTMLWriter.Write function.
|
||||
func TestHTMLWriterWrite(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
header string
|
||||
footer string
|
||||
content string
|
||||
wantString string
|
||||
}{
|
||||
{
|
||||
name: "no header or footer",
|
||||
header: "",
|
||||
footer: "",
|
||||
content: "<h1>Test Content</h1>",
|
||||
wantString: "<h1>Test Content</h1>",
|
||||
},
|
||||
{
|
||||
name: "with header only",
|
||||
header: "<!DOCTYPE html>\n<html>\n",
|
||||
footer: "",
|
||||
content: "<body>Content</body>",
|
||||
wantString: "<!DOCTYPE html>\n<html>\n<body>Content</body>",
|
||||
},
|
||||
{
|
||||
name: "with footer only",
|
||||
header: "",
|
||||
footer: testutil.TestHTMLClosingTag,
|
||||
content: "<body>Content</body>",
|
||||
wantString: "<body>Content</body>\n</html>",
|
||||
},
|
||||
{
|
||||
name: "with both header and footer",
|
||||
header: "<!DOCTYPE html>\n<html>\n<body>\n",
|
||||
footer: "\n</body>\n</html>",
|
||||
content: "<h1>Main Content</h1>",
|
||||
wantString: "<!DOCTYPE html>\n<html>\n<body>\n<h1>Main Content</h1>\n</body>\n</html>",
|
||||
},
|
||||
{
|
||||
name: "empty content",
|
||||
header: "<header>",
|
||||
footer: "</footer>",
|
||||
content: "",
|
||||
wantString: "<header></footer>",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
outputPath := filepath.Join(tmpDir, "test.html")
|
||||
|
||||
writer := &HTMLWriter{
|
||||
Header: tt.header,
|
||||
Footer: tt.footer,
|
||||
}
|
||||
|
||||
err := writer.Write(tt.content, outputPath)
|
||||
if err != nil {
|
||||
t.Errorf("Write() unexpected error = %v", err)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// Read the file and verify content
|
||||
content, err := os.ReadFile(mustSafePath(t, outputPath))
|
||||
if err != nil {
|
||||
t.Fatalf(testutil.TestMsgFailedToReadOutput, err)
|
||||
}
|
||||
|
||||
got := string(content)
|
||||
if got != tt.wantString {
|
||||
t.Errorf("Write() content = %q, want %q", got, tt.wantString)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestHTMLWriterWriteErrorPaths tests error handling in HTMLWriter.Write.
|
||||
func TestHTMLWriterWriteErrorPaths(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
setupPath func(t *testing.T) string
|
||||
skipReason string
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "invalid path - directory doesn't exist",
|
||||
setupPath: func(t *testing.T) string {
|
||||
t.Helper()
|
||||
tmpDir := t.TempDir()
|
||||
|
||||
return filepath.Join(tmpDir, "nonexistent", "file.html")
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "permission denied - unwritable directory",
|
||||
setupPath: func(t *testing.T) string {
|
||||
t.Helper()
|
||||
// Skip on Windows (chmod behavior differs)
|
||||
if runtime.GOOS == "windows" {
|
||||
return ""
|
||||
}
|
||||
// Skip if running as root (can write anywhere)
|
||||
if os.Geteuid() == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
restrictedDir := filepath.Join(tmpDir, "restricted")
|
||||
if err := os.Mkdir(restrictedDir, 0700); err != nil {
|
||||
t.Fatalf("failed to create restricted dir: %v", err)
|
||||
}
|
||||
|
||||
// Make directory unwritable
|
||||
if err := os.Chmod(restrictedDir, 0000); err != nil {
|
||||
t.Fatalf("failed to chmod: %v", err)
|
||||
}
|
||||
|
||||
// Restore permissions in cleanup
|
||||
t.Cleanup(func() {
|
||||
_ = os.Chmod(restrictedDir, 0700) // #nosec G302 -- directory needs exec bit for cleanup
|
||||
})
|
||||
|
||||
return filepath.Join(restrictedDir, "file.html")
|
||||
},
|
||||
skipReason: "skipped on Windows or when running as root",
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
path := tt.setupPath(t)
|
||||
if path == "" {
|
||||
t.Skip(tt.skipReason)
|
||||
}
|
||||
|
||||
writer := &HTMLWriter{
|
||||
Header: "<header>",
|
||||
Footer: "</footer>",
|
||||
}
|
||||
|
||||
err := writer.Write("<content>", path)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("Write() error = %v, wantErr %v", err, tt.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestHTMLWriterWriteLargeContent tests writing large HTML content.
|
||||
func TestHTMLWriterWriteLargeContent(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
outputPath := filepath.Join(tmpDir, "large.html")
|
||||
|
||||
// Create large content (10MB)
|
||||
largeContent := strings.Repeat("<p>Test content line</p>\n", 500000)
|
||||
|
||||
writer := &HTMLWriter{
|
||||
Header: "<!DOCTYPE html>\n",
|
||||
Footer: testutil.TestHTMLClosingTag,
|
||||
}
|
||||
|
||||
err := writer.Write(largeContent, outputPath)
|
||||
if err != nil {
|
||||
t.Errorf("Write() failed for large content: %v", err)
|
||||
}
|
||||
|
||||
// Verify file was created and has correct size
|
||||
info, err := os.Stat(outputPath)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to stat output file: %v", err)
|
||||
}
|
||||
|
||||
expectedSize := len("<!DOCTYPE html>\n") + len(largeContent) + len(testutil.TestHTMLClosingTag)
|
||||
if int(info.Size()) != expectedSize {
|
||||
t.Errorf("File size = %d, want %d", info.Size(), expectedSize)
|
||||
}
|
||||
}
|
||||
|
||||
// TestHTMLWriterWriteSpecialCharacters tests writing HTML with special characters.
|
||||
func TestHTMLWriterWriteSpecialCharacters(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
outputPath := filepath.Join(tmpDir, "special.html")
|
||||
|
||||
// Content with HTML entities and special characters
|
||||
content := `<div><script>alert("test")</script></div>
|
||||
<p>Special chars: & " ' < ></p>
|
||||
<p>Unicode: 你好 مرحبا привет 🎉</p>`
|
||||
|
||||
writer := &HTMLWriter{}
|
||||
err := writer.Write(content, outputPath)
|
||||
if err != nil {
|
||||
t.Errorf("Write() failed for special characters: %v", err)
|
||||
}
|
||||
|
||||
// Verify content was written correctly
|
||||
readContent, err := os.ReadFile(mustSafePath(t, outputPath))
|
||||
if err != nil {
|
||||
t.Fatalf(testutil.TestMsgFailedToReadOutput, err)
|
||||
}
|
||||
|
||||
if string(readContent) != content {
|
||||
t.Errorf("Content mismatch for special characters")
|
||||
}
|
||||
}
|
||||
|
||||
// TestHTMLWriterWriteOverwrite tests overwriting an existing file.
|
||||
func TestHTMLWriterWriteOverwrite(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
outputPath := filepath.Join(tmpDir, "overwrite.html")
|
||||
|
||||
// Write initial content
|
||||
writer := &HTMLWriter{}
|
||||
err := writer.Write("Initial content", outputPath)
|
||||
if err != nil {
|
||||
t.Fatalf("Initial write failed: %v", err)
|
||||
}
|
||||
|
||||
// Overwrite with new content
|
||||
err = writer.Write(testutil.TestHTMLNewContent, outputPath)
|
||||
if err != nil {
|
||||
t.Errorf("Overwrite failed: %v", err)
|
||||
}
|
||||
|
||||
// Verify new content
|
||||
content, err := os.ReadFile(mustSafePath(t, outputPath))
|
||||
if err != nil {
|
||||
t.Fatalf(testutil.TestMsgFailedToReadOutput, err)
|
||||
}
|
||||
|
||||
if string(content) != testutil.TestHTMLNewContent {
|
||||
t.Errorf("Content = %q, want %q", string(content), testutil.TestHTMLNewContent)
|
||||
}
|
||||
}
|
||||
|
||||
// TestHTMLWriterWriteEmptyPath tests writing to an empty path.
|
||||
func TestHTMLWriterWriteEmptyPath(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
writer := &HTMLWriter{}
|
||||
err := writer.Write("content", "")
|
||||
|
||||
// Empty path should cause an error
|
||||
if err == nil {
|
||||
t.Error("Write() with empty path should return error")
|
||||
}
|
||||
}
|
||||
|
||||
// TestHTMLWriterWriteValidPath tests writing to a valid nested path.
|
||||
func TestHTMLWriterWriteValidPath(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
|
||||
// Create nested directory structure
|
||||
nestedDir := filepath.Join(tmpDir, "nested", "directory")
|
||||
testutil.CreateTestDir(t, nestedDir)
|
||||
|
||||
outputPath := filepath.Join(nestedDir, "nested.html")
|
||||
|
||||
writer := &HTMLWriter{
|
||||
Header: "<html>",
|
||||
Footer: "</html>",
|
||||
}
|
||||
|
||||
err := writer.Write("<body>Nested content</body>", outputPath)
|
||||
if err != nil {
|
||||
t.Errorf("Write() to nested path failed: %v", err)
|
||||
}
|
||||
|
||||
// Verify file exists
|
||||
if _, err := os.Stat(outputPath); os.IsNotExist(err) {
|
||||
t.Error("File was not created in nested path")
|
||||
}
|
||||
}
|
||||
@@ -38,8 +38,8 @@ type ProgressReporter interface {
|
||||
Progress(format string, args ...any)
|
||||
}
|
||||
|
||||
// OutputConfig provides configuration queries for output behavior.
|
||||
type OutputConfig interface {
|
||||
// QuietChecker provides queries for quiet mode behavior.
|
||||
type QuietChecker interface {
|
||||
IsQuiet() bool
|
||||
}
|
||||
|
||||
@@ -61,7 +61,7 @@ type ProgressManager interface {
|
||||
type OutputWriter interface {
|
||||
MessageLogger
|
||||
ProgressReporter
|
||||
OutputConfig
|
||||
QuietChecker
|
||||
}
|
||||
|
||||
// ErrorManager combines error reporting and formatting for comprehensive error handling.
|
||||
@@ -77,5 +77,5 @@ type CompleteOutput interface {
|
||||
ErrorReporter
|
||||
ErrorFormatter
|
||||
ProgressReporter
|
||||
OutputConfig
|
||||
QuietChecker
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
package internal
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -10,6 +11,7 @@ import (
|
||||
|
||||
"github.com/ivuorinen/gh-action-readme/appconstants"
|
||||
"github.com/ivuorinen/gh-action-readme/internal/apperrors"
|
||||
"github.com/ivuorinen/gh-action-readme/testutil"
|
||||
)
|
||||
|
||||
// MockMessageLogger implements MessageLogger for testing.
|
||||
@@ -22,28 +24,33 @@ type MockMessageLogger struct {
|
||||
}
|
||||
|
||||
func (m *MockMessageLogger) Info(format string, args ...any) {
|
||||
m.InfoCalls = append(m.InfoCalls, formatMessage(format, args...))
|
||||
m.recordCall(&m.InfoCalls, format, args...)
|
||||
}
|
||||
|
||||
func (m *MockMessageLogger) Success(format string, args ...any) {
|
||||
m.SuccessCalls = append(m.SuccessCalls, formatMessage(format, args...))
|
||||
m.recordCall(&m.SuccessCalls, format, args...)
|
||||
}
|
||||
|
||||
func (m *MockMessageLogger) Warning(format string, args ...any) {
|
||||
m.WarningCalls = append(m.WarningCalls, formatMessage(format, args...))
|
||||
m.recordCall(&m.WarningCalls, format, args...)
|
||||
}
|
||||
|
||||
func (m *MockMessageLogger) Bold(format string, args ...any) {
|
||||
m.BoldCalls = append(m.BoldCalls, formatMessage(format, args...))
|
||||
m.recordCall(&m.BoldCalls, format, args...)
|
||||
}
|
||||
|
||||
func (m *MockMessageLogger) Printf(format string, args ...any) {
|
||||
m.PrintfCalls = append(m.PrintfCalls, formatMessage(format, args...))
|
||||
m.recordCall(&m.PrintfCalls, format, args...)
|
||||
}
|
||||
|
||||
func (m *MockMessageLogger) Fprintf(_ *os.File, format string, args ...any) {
|
||||
// For testing, just track the formatted message
|
||||
m.PrintfCalls = append(m.PrintfCalls, formatMessage(format, args...))
|
||||
m.recordCall(&m.PrintfCalls, format, args...)
|
||||
}
|
||||
|
||||
// recordCall is a helper to reduce duplication in mock methods.
|
||||
func (m *MockMessageLogger) recordCall(callSlice *[]string, format string, args ...any) {
|
||||
*callSlice = append(*callSlice, fmt.Sprintf(format, args...))
|
||||
}
|
||||
|
||||
// MockErrorReporter implements ErrorReporter for testing.
|
||||
@@ -55,7 +62,7 @@ type MockErrorReporter struct {
|
||||
}
|
||||
|
||||
func (m *MockErrorReporter) Error(format string, args ...any) {
|
||||
m.ErrorCalls = append(m.ErrorCalls, formatMessage(format, args...))
|
||||
m.recordCall(&m.ErrorCalls, format, args...)
|
||||
}
|
||||
|
||||
func (m *MockErrorReporter) ErrorWithSuggestions(err *apperrors.ContextualError) {
|
||||
@@ -72,21 +79,31 @@ func (m *MockErrorReporter) ErrorWithSimpleFix(message, suggestion string) {
|
||||
m.ErrorWithSimpleFixCalls = append(m.ErrorWithSimpleFixCalls, message+": "+suggestion)
|
||||
}
|
||||
|
||||
// recordCall is a helper to reduce duplication in mock methods.
|
||||
func (m *MockErrorReporter) recordCall(callSlice *[]string, format string, args ...any) {
|
||||
*callSlice = append(*callSlice, fmt.Sprintf(format, args...))
|
||||
}
|
||||
|
||||
// MockProgressReporter implements ProgressReporter for testing.
|
||||
type MockProgressReporter struct {
|
||||
ProgressCalls []string
|
||||
}
|
||||
|
||||
func (m *MockProgressReporter) Progress(format string, args ...any) {
|
||||
m.ProgressCalls = append(m.ProgressCalls, formatMessage(format, args...))
|
||||
m.recordCall(&m.ProgressCalls, format, args...)
|
||||
}
|
||||
|
||||
// MockOutputConfig implements OutputConfig for testing.
|
||||
type MockOutputConfig struct {
|
||||
// recordCall is a helper to reduce duplication in mock methods.
|
||||
func (m *MockProgressReporter) recordCall(callSlice *[]string, format string, args ...any) {
|
||||
*callSlice = append(*callSlice, fmt.Sprintf(format, args...))
|
||||
}
|
||||
|
||||
// MockQuietChecker implements QuietChecker for testing.
|
||||
type MockQuietChecker struct {
|
||||
QuietMode bool
|
||||
}
|
||||
|
||||
func (m *MockOutputConfig) IsQuiet() bool {
|
||||
func (m *MockQuietChecker) IsQuiet() bool {
|
||||
return m.QuietMode
|
||||
}
|
||||
|
||||
@@ -101,7 +118,7 @@ type MockProgressManager struct {
|
||||
}
|
||||
|
||||
func (m *MockProgressManager) CreateProgressBar(description string, total int) *progressbar.ProgressBar {
|
||||
m.CreateProgressBarCalls = append(m.CreateProgressBarCalls, formatMessage("%s (total: %d)", description, total))
|
||||
m.CreateProgressBarCalls = append(m.CreateProgressBarCalls, fmt.Sprintf("%s (total: %d)", description, total))
|
||||
|
||||
return nil // Return nil for mock to avoid actual progress bar
|
||||
}
|
||||
@@ -109,7 +126,7 @@ func (m *MockProgressManager) CreateProgressBar(description string, total int) *
|
||||
func (m *MockProgressManager) CreateProgressBarForFiles(description string, files []string) *progressbar.ProgressBar {
|
||||
m.CreateProgressBarForFilesCalls = append(
|
||||
m.CreateProgressBarForFilesCalls,
|
||||
formatMessage("%s (files: %d)", description, len(files)),
|
||||
fmt.Sprintf("%s (files: %d)", description, len(files)),
|
||||
)
|
||||
|
||||
return nil // Return nil for mock to avoid actual progress bar
|
||||
@@ -134,7 +151,7 @@ func (m *MockProgressManager) ProcessWithProgressBar(
|
||||
) {
|
||||
m.ProcessWithProgressBarCalls = append(
|
||||
m.ProcessWithProgressBarCalls,
|
||||
formatMessage("%s (items: %d)", description, len(items)),
|
||||
fmt.Sprintf("%s (items: %d)", description, len(items)),
|
||||
)
|
||||
// Execute the process function for each item
|
||||
for _, item := range items {
|
||||
@@ -142,67 +159,18 @@ func (m *MockProgressManager) ProcessWithProgressBar(
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function to format messages consistently.
|
||||
func formatMessage(format string, args ...any) string {
|
||||
if len(args) == 0 {
|
||||
return format
|
||||
}
|
||||
// Simple formatting for test purposes
|
||||
result := format
|
||||
for _, arg := range args {
|
||||
result = strings.Replace(result, "%s", toString(arg), 1)
|
||||
result = strings.Replace(result, "%d", toString(arg), 1)
|
||||
result = strings.Replace(result, "%v", toString(arg), 1)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
func toString(v any) string {
|
||||
switch val := v.(type) {
|
||||
case string:
|
||||
return val
|
||||
case int:
|
||||
return formatInt(val)
|
||||
default:
|
||||
return "unknown"
|
||||
}
|
||||
}
|
||||
|
||||
func formatInt(i int) string {
|
||||
// Simple int to string conversion for testing
|
||||
if i == 0 {
|
||||
return "0"
|
||||
}
|
||||
result := ""
|
||||
negative := i < 0
|
||||
if negative {
|
||||
i = -i
|
||||
}
|
||||
for i > 0 {
|
||||
digit := i % 10
|
||||
result = string(rune('0'+digit)) + result
|
||||
i /= 10
|
||||
}
|
||||
if negative {
|
||||
result = "-" + result
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// Test that demonstrates improved testability with focused interfaces.
|
||||
func TestFocusedInterfaces_SimpleLogger(t *testing.T) {
|
||||
func TestFocusedInterfacesSimpleLogger(t *testing.T) {
|
||||
t.Parallel()
|
||||
mockLogger := &MockMessageLogger{}
|
||||
simpleLogger := NewSimpleLogger(mockLogger)
|
||||
|
||||
// Test successful operation
|
||||
simpleLogger.LogOperation("test-operation", true)
|
||||
simpleLogger.LogOperation(testutil.TestOperationName, true)
|
||||
|
||||
// Verify the expected calls were made
|
||||
if len(mockLogger.InfoCalls) != 1 {
|
||||
t.Errorf("expected 1 Info call, got %d", len(mockLogger.InfoCalls))
|
||||
t.Errorf(testutil.TestMsgExpected1InfoCall, len(mockLogger.InfoCalls))
|
||||
}
|
||||
if len(mockLogger.SuccessCalls) != 1 {
|
||||
t.Errorf("expected 1 Success call, got %d", len(mockLogger.SuccessCalls))
|
||||
@@ -212,16 +180,20 @@ func TestFocusedInterfaces_SimpleLogger(t *testing.T) {
|
||||
}
|
||||
|
||||
// Check message content
|
||||
if !strings.Contains(mockLogger.InfoCalls[0], "test-operation") {
|
||||
t.Errorf("expected Info call to contain 'test-operation', got: %s", mockLogger.InfoCalls[0])
|
||||
if !strings.Contains(mockLogger.InfoCalls[0], testutil.TestOperationName) {
|
||||
t.Errorf("expected Info call to contain '%s', got: %s", testutil.TestOperationName, mockLogger.InfoCalls[0])
|
||||
}
|
||||
|
||||
if !strings.Contains(mockLogger.SuccessCalls[0], "test-operation") {
|
||||
t.Errorf("expected Success call to contain 'test-operation', got: %s", mockLogger.SuccessCalls[0])
|
||||
if !strings.Contains(mockLogger.SuccessCalls[0], testutil.TestOperationName) {
|
||||
t.Errorf(
|
||||
"expected Success call to contain '%s', got: %s",
|
||||
testutil.TestOperationName,
|
||||
mockLogger.SuccessCalls[0],
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFocusedInterfaces_SimpleLogger_WithFailure(t *testing.T) {
|
||||
func TestFocusedInterfacesSimpleLoggerWithFailure(t *testing.T) {
|
||||
t.Parallel()
|
||||
mockLogger := &MockMessageLogger{}
|
||||
simpleLogger := NewSimpleLogger(mockLogger)
|
||||
@@ -231,7 +203,7 @@ func TestFocusedInterfaces_SimpleLogger_WithFailure(t *testing.T) {
|
||||
|
||||
// Verify the expected calls were made
|
||||
if len(mockLogger.InfoCalls) != 1 {
|
||||
t.Errorf("expected 1 Info call, got %d", len(mockLogger.InfoCalls))
|
||||
t.Errorf(testutil.TestMsgExpected1InfoCall, len(mockLogger.InfoCalls))
|
||||
}
|
||||
if len(mockLogger.SuccessCalls) != 0 {
|
||||
t.Errorf("expected 0 Success calls, got %d", len(mockLogger.SuccessCalls))
|
||||
@@ -241,10 +213,10 @@ func TestFocusedInterfaces_SimpleLogger_WithFailure(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestFocusedInterfaces_ErrorManager(t *testing.T) {
|
||||
func TestFocusedInterfacesErrorManager(t *testing.T) {
|
||||
t.Parallel()
|
||||
mockReporter := &MockErrorReporter{}
|
||||
mockFormatter := &MockErrorFormatter{}
|
||||
mockFormatter := &errorFormatterWrapper{&testutil.ErrorFormatterMock{}}
|
||||
mockManager := &mockErrorManager{
|
||||
reporter: mockReporter,
|
||||
formatter: mockFormatter,
|
||||
@@ -264,7 +236,7 @@ func TestFocusedInterfaces_ErrorManager(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestFocusedInterfaces_TaskProgress(t *testing.T) {
|
||||
func TestFocusedInterfacesTaskProgress(t *testing.T) {
|
||||
t.Parallel()
|
||||
mockReporter := &MockProgressReporter{}
|
||||
taskProgress := NewTaskProgress(mockReporter)
|
||||
@@ -282,7 +254,7 @@ func TestFocusedInterfaces_TaskProgress(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestFocusedInterfaces_ConfigAwareComponent(t *testing.T) {
|
||||
func TestFocusedInterfacesConfigAwareComponent(t *testing.T) {
|
||||
t.Parallel()
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -304,7 +276,7 @@ func TestFocusedInterfaces_ConfigAwareComponent(t *testing.T) {
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
mockConfig := &MockOutputConfig{QuietMode: tt.quietMode}
|
||||
mockConfig := &MockQuietChecker{QuietMode: tt.quietMode}
|
||||
component := NewConfigAwareComponent(mockConfig)
|
||||
|
||||
result := component.ShouldOutput()
|
||||
@@ -316,12 +288,12 @@ func TestFocusedInterfaces_ConfigAwareComponent(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestFocusedInterfaces_CompositeOutputWriter(t *testing.T) {
|
||||
func TestFocusedInterfacesCompositeOutputWriter(t *testing.T) {
|
||||
t.Parallel()
|
||||
// Create a composite mock that implements OutputWriter
|
||||
mockLogger := &MockMessageLogger{}
|
||||
mockProgress := &MockProgressReporter{}
|
||||
mockConfig := &MockOutputConfig{QuietMode: false}
|
||||
mockConfig := &MockQuietChecker{QuietMode: false}
|
||||
|
||||
compositeWriter := &CompositeOutputWriter{
|
||||
writer: &mockOutputWriter{
|
||||
@@ -337,7 +309,7 @@ func TestFocusedInterfaces_CompositeOutputWriter(t *testing.T) {
|
||||
// Verify that the composite writer uses both message logging and progress reporting
|
||||
// Should have called Info and Success for overall status
|
||||
if len(mockLogger.InfoCalls) != 1 {
|
||||
t.Errorf("expected 1 Info call, got %d", len(mockLogger.InfoCalls))
|
||||
t.Errorf(testutil.TestMsgExpected1InfoCall, len(mockLogger.InfoCalls))
|
||||
}
|
||||
if len(mockLogger.SuccessCalls) != 1 {
|
||||
t.Errorf("expected 1 Success call, got %d", len(mockLogger.SuccessCalls))
|
||||
@@ -349,15 +321,15 @@ func TestFocusedInterfaces_CompositeOutputWriter(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestFocusedInterfaces_GeneratorWithDependencyInjection(t *testing.T) {
|
||||
func TestFocusedInterfacesGeneratorWithDependencyInjection(t *testing.T) {
|
||||
t.Parallel()
|
||||
// Create focused mocks
|
||||
mockOutput := &mockCompleteOutput{
|
||||
logger: &MockMessageLogger{},
|
||||
reporter: &MockErrorReporter{},
|
||||
formatter: &MockErrorFormatter{},
|
||||
formatter: &errorFormatterWrapper{&testutil.ErrorFormatterMock{}},
|
||||
progress: &MockProgressReporter{},
|
||||
config: &MockOutputConfig{QuietMode: false},
|
||||
config: &MockQuietChecker{QuietMode: false},
|
||||
}
|
||||
mockProgress := &MockProgressManager{}
|
||||
|
||||
@@ -394,7 +366,7 @@ type mockCompleteOutput struct {
|
||||
reporter ErrorReporter
|
||||
formatter ErrorFormatter
|
||||
progress ProgressReporter
|
||||
config OutputConfig
|
||||
config QuietChecker
|
||||
}
|
||||
|
||||
func (m *mockCompleteOutput) Info(format string, args ...any) { m.logger.Info(format, args...) }
|
||||
@@ -426,7 +398,7 @@ func (m *mockCompleteOutput) IsQuiet() bool { return m.config.IsQuiet() }
|
||||
type mockOutputWriter struct {
|
||||
logger MessageLogger
|
||||
reporter ProgressReporter
|
||||
config OutputConfig
|
||||
config QuietChecker
|
||||
}
|
||||
|
||||
func (m *mockOutputWriter) Info(format string, args ...any) { m.logger.Info(format, args...) }
|
||||
@@ -440,20 +412,14 @@ func (m *mockOutputWriter) Fprintf(w *os.File, format string, args ...any) {
|
||||
func (m *mockOutputWriter) Progress(format string, args ...any) { m.reporter.Progress(format, args...) }
|
||||
func (m *mockOutputWriter) IsQuiet() bool { return m.config.IsQuiet() }
|
||||
|
||||
// MockErrorFormatter implements ErrorFormatter for testing.
|
||||
type MockErrorFormatter struct {
|
||||
FormatContextualErrorCalls []string
|
||||
// errorFormatterWrapper wraps testutil.ErrorFormatterMock to implement ErrorFormatter interface.
|
||||
type errorFormatterWrapper struct {
|
||||
*testutil.ErrorFormatterMock
|
||||
}
|
||||
|
||||
func (m *MockErrorFormatter) FormatContextualError(err *apperrors.ContextualError) string {
|
||||
if err != nil {
|
||||
formatted := err.Error()
|
||||
m.FormatContextualErrorCalls = append(m.FormatContextualErrorCalls, formatted)
|
||||
|
||||
return formatted
|
||||
}
|
||||
|
||||
return ""
|
||||
// FormatContextualError adapts the generic error interface to ContextualError.
|
||||
func (w *errorFormatterWrapper) FormatContextualError(err *apperrors.ContextualError) string {
|
||||
return w.ErrorFormatterMock.FormatContextualError(err)
|
||||
}
|
||||
|
||||
// mockErrorManager implements ErrorManager for testing.
|
||||
|
||||
@@ -6,7 +6,7 @@ import (
|
||||
"github.com/ivuorinen/gh-action-readme/testutil"
|
||||
)
|
||||
|
||||
func TestParseActionYML_Valid(t *testing.T) {
|
||||
func TestParseActionYMLValid(t *testing.T) {
|
||||
t.Parallel()
|
||||
// Create temporary action file using fixture
|
||||
actionPath := testutil.CreateTemporaryAction(t, "actions/javascript/simple.yml")
|
||||
@@ -25,7 +25,7 @@ func TestParseActionYML_Valid(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseActionYML_MissingFile(t *testing.T) {
|
||||
func TestParseActionYMLMissingFile(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, err := ParseActionYML("notfound/action.yml")
|
||||
if err == nil {
|
||||
|
||||
@@ -21,7 +21,7 @@ func TestRenderReadme(t *testing.T) {
|
||||
"foo": {Description: "Foo input", Required: true},
|
||||
},
|
||||
}
|
||||
tmpl := filepath.Join(tmpDir, "templates", "readme.tmpl")
|
||||
tmpl := filepath.Join(tmpDir, "templates", testutil.TestTemplateReadme)
|
||||
opts := TemplateOptions{TemplatePath: tmpl, Format: "md"}
|
||||
out, err := RenderReadme(action, opts)
|
||||
if err != nil {
|
||||
|
||||
@@ -2,7 +2,7 @@ package internal
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestValidateActionYML_Required(t *testing.T) {
|
||||
func TestValidateActionYMLRequired(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
a := &ActionYML{
|
||||
@@ -16,7 +16,7 @@ func TestValidateActionYML_Required(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateActionYML_Valid(t *testing.T) {
|
||||
func TestValidateActionYMLValid(t *testing.T) {
|
||||
t.Parallel()
|
||||
a := &ActionYML{
|
||||
Name: "MyAction",
|
||||
|
||||
@@ -40,6 +40,7 @@ type ActionYMLForJSON struct {
|
||||
Outputs map[string]ActionOutputForJSON `json:"outputs,omitempty"`
|
||||
Runs map[string]any `json:"runs"`
|
||||
Branding *BrandingForJSON `json:"branding,omitempty"`
|
||||
Permissions map[string]string `json:"permissions,omitempty"`
|
||||
}
|
||||
|
||||
// ActionInputForJSON represents an input parameter in JSON format.
|
||||
@@ -218,6 +219,7 @@ func (jw *JSONWriter) convertToJSONOutput(action *ActionYML) *JSONOutput {
|
||||
Outputs: outputs,
|
||||
Runs: action.Runs,
|
||||
Branding: branding,
|
||||
Permissions: action.Permissions,
|
||||
},
|
||||
Documentation: DocumentationInfo{
|
||||
Title: action.Name,
|
||||
@@ -226,8 +228,8 @@ func (jw *JSONWriter) convertToJSONOutput(action *ActionYML) *JSONOutput {
|
||||
Badges: badges,
|
||||
Sections: sections,
|
||||
Links: map[string]string{
|
||||
"action.yml": "./action.yml",
|
||||
"repository": "https://github.com/your-org/" + action.Name,
|
||||
appconstants.ActionFileNameYML: "./" + appconstants.ActionFileNameYML,
|
||||
"repository": "https://github.com/your-org/" + action.Name,
|
||||
},
|
||||
},
|
||||
Examples: examples,
|
||||
|
||||
@@ -24,7 +24,7 @@ var (
|
||||
_ ErrorReporter = (*ColoredOutput)(nil)
|
||||
_ ErrorFormatter = (*ColoredOutput)(nil)
|
||||
_ ProgressReporter = (*ColoredOutput)(nil)
|
||||
_ OutputConfig = (*ColoredOutput)(nil)
|
||||
_ QuietChecker = (*ColoredOutput)(nil)
|
||||
_ CompleteOutput = (*ColoredOutput)(nil)
|
||||
)
|
||||
|
||||
@@ -43,14 +43,7 @@ func (co *ColoredOutput) IsQuiet() bool {
|
||||
|
||||
// Success prints a success message in green.
|
||||
func (co *ColoredOutput) Success(format string, args ...any) {
|
||||
if co.Quiet {
|
||||
return
|
||||
}
|
||||
if co.NoColor {
|
||||
fmt.Printf("✅ "+format+"\n", args...)
|
||||
} else {
|
||||
color.Green("✅ "+format, args...)
|
||||
}
|
||||
co.printWithIcon("✅", format, color.Green, args...)
|
||||
}
|
||||
|
||||
// Error prints an error message in red to stderr.
|
||||
@@ -64,38 +57,17 @@ func (co *ColoredOutput) Error(format string, args ...any) {
|
||||
|
||||
// Warning prints a warning message in yellow.
|
||||
func (co *ColoredOutput) Warning(format string, args ...any) {
|
||||
if co.Quiet {
|
||||
return
|
||||
}
|
||||
if co.NoColor {
|
||||
fmt.Printf("⚠️ "+format+"\n", args...)
|
||||
} else {
|
||||
color.Yellow("⚠️ "+format, args...)
|
||||
}
|
||||
co.printWithIcon("⚠️ ", format, color.Yellow, args...)
|
||||
}
|
||||
|
||||
// Info prints an info message in blue.
|
||||
func (co *ColoredOutput) Info(format string, args ...any) {
|
||||
if co.Quiet {
|
||||
return
|
||||
}
|
||||
if co.NoColor {
|
||||
fmt.Printf("ℹ️ "+format+"\n", args...)
|
||||
} else {
|
||||
color.Blue("ℹ️ "+format, args...)
|
||||
}
|
||||
co.printWithIcon("ℹ️ ", format, color.Blue, args...)
|
||||
}
|
||||
|
||||
// Progress prints a progress message in cyan.
|
||||
func (co *ColoredOutput) Progress(format string, args ...any) {
|
||||
if co.Quiet {
|
||||
return
|
||||
}
|
||||
if co.NoColor {
|
||||
fmt.Printf("🔄 "+format+"\n", args...)
|
||||
} else {
|
||||
color.Cyan("🔄 "+format, args...)
|
||||
}
|
||||
co.printWithIcon("🔄", format, color.Cyan, args...)
|
||||
}
|
||||
|
||||
// Bold prints text in bold.
|
||||
@@ -194,6 +166,20 @@ func (co *ColoredOutput) FormatContextualError(err *apperrors.ContextualError) s
|
||||
return strings.Join(parts, "\n")
|
||||
}
|
||||
|
||||
// printWithIcon is a helper for printing messages with icons and colors.
|
||||
// It handles quiet mode, color toggling, and consistent formatting.
|
||||
func (co *ColoredOutput) printWithIcon(icon, format string, colorFunc func(string, ...any), args ...any) {
|
||||
if co.Quiet {
|
||||
return
|
||||
}
|
||||
message := icon + " " + format
|
||||
if co.NoColor {
|
||||
fmt.Printf(message+"\n", args...)
|
||||
} else {
|
||||
colorFunc(message, args...)
|
||||
}
|
||||
}
|
||||
|
||||
// formatMainError formats the main error message with code.
|
||||
func (co *ColoredOutput) formatMainError(err *apperrors.ContextualError) string {
|
||||
mainMsg := fmt.Sprintf("%s [%s]", err.Error(), err.Code)
|
||||
@@ -204,15 +190,19 @@ func (co *ColoredOutput) formatMainError(err *apperrors.ContextualError) string
|
||||
return color.RedString("❌ ") + mainMsg
|
||||
}
|
||||
|
||||
// formatBoldSection formats a section header with or without color.
|
||||
func (co *ColoredOutput) formatBoldSection(section string) string {
|
||||
if co.NoColor {
|
||||
return section
|
||||
}
|
||||
|
||||
return color.New(color.Bold).Sprint(section)
|
||||
}
|
||||
|
||||
// formatDetailsSection formats the details section.
|
||||
func (co *ColoredOutput) formatDetailsSection(details map[string]string) []string {
|
||||
var parts []string
|
||||
|
||||
if co.NoColor {
|
||||
parts = append(parts, appconstants.SectionDetails)
|
||||
} else {
|
||||
parts = append(parts, color.New(color.Bold).Sprint(appconstants.SectionDetails))
|
||||
}
|
||||
parts = append(parts, co.formatBoldSection(appconstants.SectionDetails))
|
||||
|
||||
for key, value := range details {
|
||||
if co.NoColor {
|
||||
@@ -230,12 +220,7 @@ func (co *ColoredOutput) formatDetailsSection(details map[string]string) []strin
|
||||
// formatSuggestionsSection formats the suggestions section.
|
||||
func (co *ColoredOutput) formatSuggestionsSection(suggestions []string) []string {
|
||||
var parts []string
|
||||
|
||||
if co.NoColor {
|
||||
parts = append(parts, appconstants.SectionSuggestions)
|
||||
} else {
|
||||
parts = append(parts, color.New(color.Bold).Sprint(appconstants.SectionSuggestions))
|
||||
}
|
||||
parts = append(parts, co.formatBoldSection(appconstants.SectionSuggestions))
|
||||
|
||||
for _, suggestion := range suggestions {
|
||||
if co.NoColor {
|
||||
|
||||
542
internal/output_test.go
Normal file
542
internal/output_test.go
Normal file
@@ -0,0 +1,542 @@
|
||||
package internal
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/ivuorinen/gh-action-readme/appconstants"
|
||||
"github.com/ivuorinen/gh-action-readme/internal/apperrors"
|
||||
"github.com/ivuorinen/gh-action-readme/testutil"
|
||||
)
|
||||
|
||||
// testOutputMethod is a generic helper for testing output methods that follow the same pattern.
|
||||
func testOutputMethod(t *testing.T, testMessage, expectedEmoji string, methodFunc func(*ColoredOutput, string)) {
|
||||
t.Helper()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
quiet bool
|
||||
message string
|
||||
wantEmpty bool
|
||||
}{
|
||||
{
|
||||
name: "message displayed",
|
||||
quiet: false,
|
||||
message: testMessage,
|
||||
wantEmpty: false,
|
||||
},
|
||||
{
|
||||
name: testutil.TestMsgQuietSuppressOutput,
|
||||
quiet: true,
|
||||
message: testMessage,
|
||||
wantEmpty: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
output := &ColoredOutput{Quiet: tt.quiet, NoColor: true}
|
||||
|
||||
captured := testutil.CaptureStdout(func() {
|
||||
methodFunc(output, tt.message)
|
||||
})
|
||||
|
||||
if tt.wantEmpty && captured != "" {
|
||||
t.Errorf(testutil.TestMsgNoOutputInQuiet, captured)
|
||||
}
|
||||
|
||||
if !tt.wantEmpty && !strings.Contains(captured, expectedEmoji) {
|
||||
t.Errorf("Output missing %s emoji: %q", expectedEmoji, captured)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// testErrorStderr is a helper for testing error output methods that write to stderr.
|
||||
// Eliminates the repeated pattern of creating ColoredOutput, capturing stderr, and checking for emoji.
|
||||
func testErrorStderr(t *testing.T, expectedEmoji string, testFunc func(*ColoredOutput)) {
|
||||
t.Helper()
|
||||
|
||||
output := &ColoredOutput{NoColor: true}
|
||||
captured := testutil.CaptureStderr(func() {
|
||||
testFunc(output)
|
||||
})
|
||||
|
||||
if !strings.Contains(captured, expectedEmoji) {
|
||||
t.Errorf("Output missing %s emoji: %q", expectedEmoji, captured)
|
||||
}
|
||||
}
|
||||
|
||||
// TestNewColoredOutput tests colored output creation.
|
||||
func TestNewColoredOutput(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
quiet bool
|
||||
wantQuiet bool
|
||||
}{
|
||||
{
|
||||
name: testutil.TestScenarioQuietEnabled,
|
||||
quiet: true,
|
||||
wantQuiet: true,
|
||||
},
|
||||
{
|
||||
name: testutil.TestScenarioQuietDisabled,
|
||||
quiet: false,
|
||||
wantQuiet: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
output := NewColoredOutput(tt.quiet)
|
||||
|
||||
if output == nil {
|
||||
t.Fatal("NewColoredOutput() returned nil")
|
||||
}
|
||||
|
||||
if output.Quiet != tt.wantQuiet {
|
||||
t.Errorf("Quiet = %v, want %v", output.Quiet, tt.wantQuiet)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestIsQuiet tests quiet mode detection.
|
||||
func TestIsQuiet(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
quiet bool
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
name: testutil.TestScenarioQuietEnabled,
|
||||
quiet: true,
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: testutil.TestScenarioQuietDisabled,
|
||||
quiet: false,
|
||||
want: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
output := &ColoredOutput{Quiet: tt.quiet, NoColor: true}
|
||||
got := output.IsQuiet()
|
||||
|
||||
if got != tt.want {
|
||||
t.Errorf("IsQuiet() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestSuccess tests success message output.
|
||||
func TestSuccess(t *testing.T) {
|
||||
testOutputMethod(t, testutil.TestMsgOperationCompleted, "✅", func(o *ColoredOutput, msg string) {
|
||||
o.Success(msg)
|
||||
})
|
||||
}
|
||||
|
||||
// TestError tests error message output.
|
||||
func TestError(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
message string
|
||||
wantContains string
|
||||
}{
|
||||
{
|
||||
name: "error message displayed",
|
||||
message: testutil.TestMsgFileNotFound,
|
||||
wantContains: "❌ File not found",
|
||||
},
|
||||
{
|
||||
name: "error with formatting",
|
||||
message: "Failed to process %s",
|
||||
wantContains: "❌ Failed to process %!s(MISSING)",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
output := &ColoredOutput{NoColor: true}
|
||||
|
||||
captured := testutil.CaptureStderr(func() {
|
||||
output.Error(tt.message)
|
||||
})
|
||||
|
||||
if !strings.Contains(captured, "❌") {
|
||||
t.Errorf(testutil.TestMsgOutputMissingEmoji, captured)
|
||||
}
|
||||
|
||||
if !strings.Contains(captured, strings.TrimPrefix(tt.wantContains, "❌ ")) {
|
||||
t.Errorf("Output doesn't contain expected message. Got: %q", captured)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestWarning tests warning message output.
|
||||
func TestWarning(t *testing.T) {
|
||||
testOutputMethod(t, "Deprecated feature", "⚠️", func(o *ColoredOutput, msg string) {
|
||||
o.Warning(msg)
|
||||
})
|
||||
}
|
||||
|
||||
// TestInfo tests info message output.
|
||||
func TestInfo(t *testing.T) {
|
||||
testOutputMethod(t, testutil.TestMsgProcessingStarted, "ℹ️", func(o *ColoredOutput, msg string) {
|
||||
o.Info(msg)
|
||||
})
|
||||
}
|
||||
|
||||
// TestProgress tests progress message output.
|
||||
func TestProgress(t *testing.T) {
|
||||
testOutputMethod(t, "Loading data...", "🔄", func(o *ColoredOutput, msg string) {
|
||||
o.Progress(msg)
|
||||
})
|
||||
}
|
||||
|
||||
// TestBold tests bold text output.
|
||||
func TestBold(t *testing.T) {
|
||||
testOutputMethod(t, "Important Notice", "Important Notice", func(o *ColoredOutput, msg string) {
|
||||
o.Bold(msg)
|
||||
})
|
||||
}
|
||||
|
||||
// TestPrintf tests formatted print output.
|
||||
func TestPrintf(t *testing.T) {
|
||||
testOutputMethod(t, "Test message\n", "Test message", func(o *ColoredOutput, msg string) {
|
||||
o.Printf("%s", msg) // #nosec G104 -- constant format string
|
||||
})
|
||||
}
|
||||
|
||||
// TestFprintf tests file output.
|
||||
func TestFprintf(t *testing.T) {
|
||||
// Create temporary file for testing
|
||||
tmpfile, err := os.CreateTemp(t.TempDir(), "test-fprintf-*.txt")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() { _ = os.Remove(tmpfile.Name()) }() // Ignore error
|
||||
defer func() { _ = tmpfile.Close() }() // Ignore error
|
||||
|
||||
output := &ColoredOutput{NoColor: true}
|
||||
output.Fprintf(tmpfile, "Test message: %s\n", "hello")
|
||||
|
||||
// Read back the content
|
||||
_, _ = tmpfile.Seek(0, 0) // Ignore error in test
|
||||
content := make([]byte, 100)
|
||||
n, _ := tmpfile.Read(content)
|
||||
|
||||
got := string(content[:n])
|
||||
want := "Test message: hello\n"
|
||||
|
||||
if got != want {
|
||||
t.Errorf("Fprintf() wrote %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// TestErrorWithSuggestions tests contextual error output.
|
||||
func TestErrorWithSuggestions(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
err *apperrors.ContextualError
|
||||
wantContains string
|
||||
}{
|
||||
{
|
||||
name: "nil error does nothing",
|
||||
err: nil,
|
||||
wantContains: "",
|
||||
},
|
||||
{
|
||||
name: "error with suggestions",
|
||||
err: apperrors.New(appconstants.ErrCodeFileNotFound, testutil.TestMsgFileNotFound).
|
||||
WithSuggestions(testutil.TestMsgCheckFilePath),
|
||||
wantContains: "❌",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
output := &ColoredOutput{NoColor: true}
|
||||
|
||||
captured := testutil.CaptureStderr(func() {
|
||||
output.ErrorWithSuggestions(tt.err)
|
||||
})
|
||||
|
||||
if tt.wantContains == "" && captured != "" {
|
||||
t.Errorf("Expected no output for nil error, got %q", captured)
|
||||
}
|
||||
|
||||
if tt.wantContains != "" && !strings.Contains(captured, tt.wantContains) {
|
||||
t.Errorf("Output doesn't contain %q. Got: %q", tt.wantContains, captured)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestErrorWithContext tests contextual error creation and output.
|
||||
func TestErrorWithContext(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
code appconstants.ErrorCode
|
||||
message string
|
||||
context map[string]string
|
||||
}{
|
||||
{
|
||||
name: "error with context",
|
||||
code: appconstants.ErrCodeFileNotFound,
|
||||
message: testutil.TestMsgFileNotFound,
|
||||
context: map[string]string{testutil.TestKeyFile: appconstants.ActionFileNameYML},
|
||||
},
|
||||
{
|
||||
name: "error without context",
|
||||
code: appconstants.ErrCodeInvalidYAML,
|
||||
message: testutil.TestMsgInvalidYAML,
|
||||
context: nil,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
output := &ColoredOutput{NoColor: true}
|
||||
|
||||
captured := testutil.CaptureStderr(func() {
|
||||
output.ErrorWithContext(tt.code, tt.message, tt.context)
|
||||
})
|
||||
|
||||
if !strings.Contains(captured, "❌") {
|
||||
t.Errorf(testutil.TestMsgOutputMissingEmoji, captured)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestErrorWithSimpleFix tests simple error with fix output.
|
||||
func TestErrorWithSimpleFix(t *testing.T) {
|
||||
testErrorStderr(t, "❌", func(output *ColoredOutput) {
|
||||
output.ErrorWithSimpleFix("Something went wrong", "Try running it again")
|
||||
})
|
||||
}
|
||||
|
||||
// TestFormatContextualError tests contextual error formatting.
|
||||
func TestFormatContextualError(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
err *apperrors.ContextualError
|
||||
wantContains []string
|
||||
}{
|
||||
{
|
||||
name: "nil error returns empty string",
|
||||
err: nil,
|
||||
wantContains: nil,
|
||||
},
|
||||
{
|
||||
name: "error with all sections",
|
||||
err: apperrors.New(appconstants.ErrCodeFileNotFound, testutil.TestMsgFileNotFound).
|
||||
WithSuggestions(testutil.TestMsgCheckFilePath, testutil.TestMsgVerifyPermissions).
|
||||
WithDetails(map[string]string{testutil.TestKeyFile: appconstants.ActionFileNameYML}).
|
||||
WithHelpURL(testutil.TestURLHelp),
|
||||
wantContains: []string{
|
||||
"❌",
|
||||
testutil.TestMsgFileNotFound,
|
||||
testutil.TestMsgCheckFilePath,
|
||||
testutil.TestURLHelp,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "error without suggestions",
|
||||
err: apperrors.New(appconstants.ErrCodeInvalidYAML, testutil.TestMsgInvalidYAML),
|
||||
wantContains: []string{"❌", testutil.TestMsgInvalidYAML},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
output := &ColoredOutput{NoColor: true}
|
||||
got := output.FormatContextualError(tt.err)
|
||||
|
||||
if tt.err == nil && got != "" {
|
||||
t.Errorf("Expected empty string for nil error, got %q", got)
|
||||
}
|
||||
|
||||
for _, want := range tt.wantContains {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Errorf("FormatContextualError() missing %q. Got:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestFormatMainError tests main error message formatting.
|
||||
func TestFormatMainError(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
noColor bool
|
||||
err *apperrors.ContextualError
|
||||
wantContains []string
|
||||
}{
|
||||
{
|
||||
name: testutil.TestScenarioColorDisabled,
|
||||
noColor: true,
|
||||
err: apperrors.New(appconstants.ErrCodeFileNotFound, testutil.TestMsgFileNotFound),
|
||||
wantContains: []string{"❌", testutil.TestMsgFileNotFound, string(appconstants.ErrCodeFileNotFound)},
|
||||
},
|
||||
{
|
||||
name: testutil.TestScenarioColorEnabled,
|
||||
noColor: false,
|
||||
err: apperrors.New(appconstants.ErrCodeInvalidYAML, testutil.TestMsgInvalidYAML),
|
||||
wantContains: []string{"❌", testutil.TestMsgInvalidYAML, string(appconstants.ErrCodeInvalidYAML)},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
output := &ColoredOutput{NoColor: tt.noColor}
|
||||
got := output.formatMainError(tt.err)
|
||||
|
||||
for _, want := range tt.wantContains {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Errorf("formatMainError() missing %q. Got: %q", want, got)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestFormatDetailsSection tests details section formatting.
|
||||
func TestFormatDetailsSection(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
noColor bool
|
||||
details map[string]string
|
||||
wantContains []string
|
||||
}{
|
||||
{
|
||||
name: testutil.TestScenarioColorDisabled,
|
||||
noColor: true,
|
||||
details: map[string]string{testutil.TestKeyFile: appconstants.ActionFileNameYML, "line": "10"},
|
||||
wantContains: []string{
|
||||
testutil.TestMsgDetails,
|
||||
testutil.TestKeyFile,
|
||||
appconstants.ActionFileNameYML,
|
||||
"line",
|
||||
"10",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: testutil.TestScenarioColorEnabled,
|
||||
noColor: false,
|
||||
details: map[string]string{testutil.TestKeyPath: "/tmp/test"},
|
||||
wantContains: []string{testutil.TestMsgDetails, "path", "/tmp/test"},
|
||||
},
|
||||
{
|
||||
name: "empty details",
|
||||
noColor: true,
|
||||
details: map[string]string{},
|
||||
wantContains: []string{testutil.TestMsgDetails},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
output := &ColoredOutput{NoColor: tt.noColor}
|
||||
got := output.formatDetailsSection(tt.details)
|
||||
gotStr := strings.Join(got, "\n")
|
||||
|
||||
for _, want := range tt.wantContains {
|
||||
if !strings.Contains(gotStr, want) {
|
||||
t.Errorf("formatDetailsSection() missing %q. Got:\n%s", want, gotStr)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestFormatSuggestionsSection tests suggestions section formatting.
|
||||
func TestFormatSuggestionsSection(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
noColor bool
|
||||
suggestions []string
|
||||
wantContains []string
|
||||
}{
|
||||
{
|
||||
name: testutil.TestScenarioColorDisabled,
|
||||
noColor: true,
|
||||
suggestions: []string{"Check the file", testutil.TestMsgVerifyPermissions},
|
||||
wantContains: []string{
|
||||
testutil.TestMsgSuggestions,
|
||||
"•",
|
||||
"Check the file",
|
||||
testutil.TestMsgVerifyPermissions,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: testutil.TestScenarioColorEnabled,
|
||||
noColor: false,
|
||||
suggestions: []string{testutil.TestMsgTryAgain},
|
||||
wantContains: []string{testutil.TestMsgSuggestions, "•", testutil.TestMsgTryAgain},
|
||||
},
|
||||
{
|
||||
name: "empty suggestions",
|
||||
noColor: true,
|
||||
suggestions: []string{},
|
||||
wantContains: []string{testutil.TestMsgSuggestions},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
output := &ColoredOutput{NoColor: tt.noColor}
|
||||
got := output.formatSuggestionsSection(tt.suggestions)
|
||||
gotStr := strings.Join(got, "\n")
|
||||
|
||||
for _, want := range tt.wantContains {
|
||||
if !strings.Contains(gotStr, want) {
|
||||
t.Errorf("formatSuggestionsSection() missing %q. Got:\n%s", want, gotStr)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestFormatHelpURLSection tests help URL section formatting.
|
||||
func TestFormatHelpURLSection(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
noColor bool
|
||||
helpURL string
|
||||
wantContains []string
|
||||
}{
|
||||
{
|
||||
name: testutil.TestScenarioColorDisabled,
|
||||
noColor: true,
|
||||
helpURL: testutil.TestURLHelp,
|
||||
wantContains: []string{"For more help", testutil.TestURLHelp},
|
||||
},
|
||||
{
|
||||
name: testutil.TestScenarioColorEnabled,
|
||||
noColor: false,
|
||||
helpURL: "https://docs.example.com",
|
||||
wantContains: []string{"For more help", "https://docs.example.com"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
output := &ColoredOutput{NoColor: tt.noColor}
|
||||
got := output.formatHelpURLSection(tt.helpURL)
|
||||
|
||||
for _, want := range tt.wantContains {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Errorf("formatHelpURLSection() missing %q. Got: %q", want, got)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package internal
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -19,6 +20,7 @@ type ActionYML struct {
|
||||
Outputs map[string]ActionOutput `yaml:"outputs"`
|
||||
Runs map[string]any `yaml:"runs"`
|
||||
Branding *Branding `yaml:"branding,omitempty"`
|
||||
Permissions map[string]string `yaml:"permissions,omitempty"`
|
||||
// Add more fields as the schema evolves
|
||||
}
|
||||
|
||||
@@ -42,6 +44,14 @@ type Branding struct {
|
||||
|
||||
// ParseActionYML reads and parses action.yml from given path.
|
||||
func ParseActionYML(path string) (*ActionYML, error) {
|
||||
// Parse permissions from header comments FIRST
|
||||
commentPermissions, err := parsePermissionsFromComments(path)
|
||||
if err != nil {
|
||||
// Don't fail if comment parsing fails, just log and continue
|
||||
commentPermissions = nil
|
||||
}
|
||||
|
||||
// Standard YAML parsing
|
||||
f, err := os.Open(path) // #nosec G304 -- path from function parameter
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -55,49 +65,218 @@ func ParseActionYML(path string) (*ActionYML, error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Merge permissions: YAML permissions override comment permissions
|
||||
mergePermissions(&a, commentPermissions)
|
||||
|
||||
return &a, nil
|
||||
}
|
||||
|
||||
// mergePermissions combines comment and YAML permissions.
|
||||
// YAML permissions take precedence when both exist.
|
||||
func mergePermissions(action *ActionYML, commentPerms map[string]string) {
|
||||
if action.Permissions == nil && commentPerms != nil && len(commentPerms) > 0 {
|
||||
action.Permissions = commentPerms
|
||||
} else if action.Permissions != nil && commentPerms != nil && len(commentPerms) > 0 {
|
||||
// Merge: YAML takes precedence, add missing from comments
|
||||
for key, value := range commentPerms {
|
||||
if _, exists := action.Permissions[key]; !exists {
|
||||
action.Permissions[key] = value
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// parsePermissionsFromComments extracts permissions from header comments.
|
||||
// Looks for lines like:
|
||||
//
|
||||
// # permissions:
|
||||
// # - contents: read # Required for checking out repository
|
||||
// # contents: read # Alternative format without dash
|
||||
func parsePermissionsFromComments(path string) (map[string]string, error) {
|
||||
file, err := os.Open(path) // #nosec G304 -- path from function parameter
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer func() {
|
||||
_ = file.Close() // Ignore close error in defer
|
||||
}()
|
||||
|
||||
permissions := make(map[string]string)
|
||||
scanner := bufio.NewScanner(file)
|
||||
|
||||
inPermissionsBlock := false
|
||||
var expectedItemIndent int
|
||||
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
trimmed := strings.TrimSpace(line)
|
||||
|
||||
// Stop parsing at first non-comment line
|
||||
if !strings.HasPrefix(trimmed, "#") {
|
||||
break
|
||||
}
|
||||
|
||||
// Remove leading # and spaces
|
||||
content := strings.TrimPrefix(trimmed, "#")
|
||||
content = strings.TrimSpace(content)
|
||||
|
||||
// Check for permissions block start
|
||||
if content == "permissions:" {
|
||||
inPermissionsBlock = true
|
||||
// Calculate expected indent for permission items (after the # and any spaces)
|
||||
// We expect items to be indented relative to the content
|
||||
expectedItemIndent = -1 // Will be set on first item
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
// Parse permission entries
|
||||
if inPermissionsBlock && content != "" {
|
||||
shouldBreak := processPermissionEntry(line, content, &expectedItemIndent, permissions)
|
||||
if shouldBreak {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if err := scanner.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return permissions, nil
|
||||
}
|
||||
|
||||
// parsePermissionLine extracts key-value from a permission line.
|
||||
// Supports formats:
|
||||
// - "- contents: read # comment"
|
||||
// - "contents: read # comment"
|
||||
func parsePermissionLine(content string) (key, value string, ok bool) {
|
||||
// Remove leading dash if present
|
||||
content = strings.TrimPrefix(content, "-")
|
||||
content = strings.TrimSpace(content)
|
||||
|
||||
// Remove inline comments
|
||||
if idx := strings.Index(content, "#"); idx > 0 {
|
||||
content = strings.TrimSpace(content[:idx])
|
||||
}
|
||||
|
||||
// Parse key: value
|
||||
parts := strings.SplitN(content, ":", 2)
|
||||
if len(parts) == 2 {
|
||||
key = strings.TrimSpace(parts[0])
|
||||
value = strings.TrimSpace(parts[1])
|
||||
if key != "" && value != "" {
|
||||
return key, value, true
|
||||
}
|
||||
}
|
||||
|
||||
return "", "", false
|
||||
}
|
||||
|
||||
// processPermissionEntry processes a single line in the permissions block.
|
||||
// Returns true if parsing should break (dedented out of block), false to continue.
|
||||
func processPermissionEntry(line, content string, expectedItemIndent *int, permissions map[string]string) bool {
|
||||
// Get the indent of the content (after removing #)
|
||||
lineAfterHash := strings.TrimPrefix(line, "#")
|
||||
contentIndent := len(lineAfterHash) - len(strings.TrimLeft(lineAfterHash, " "))
|
||||
|
||||
// Set expected indent on first item
|
||||
if *expectedItemIndent == -1 {
|
||||
*expectedItemIndent = contentIndent
|
||||
}
|
||||
|
||||
// If dedented relative to expected item indent, we've left the permissions block
|
||||
if contentIndent < *expectedItemIndent {
|
||||
return true
|
||||
}
|
||||
|
||||
// Parse permission line and add to map if valid
|
||||
if key, value, ok := parsePermissionLine(content); ok {
|
||||
permissions[key] = value
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// shouldIgnoreDirectory checks if a directory name matches the ignore list.
|
||||
func shouldIgnoreDirectory(dirName string, ignoredDirs []string) bool {
|
||||
for _, ignored := range ignoredDirs {
|
||||
if strings.HasPrefix(ignored, ".") {
|
||||
// Pattern match: ".git" matches ".git", ".github", etc.
|
||||
if strings.HasPrefix(dirName, ignored) {
|
||||
return true
|
||||
}
|
||||
} else {
|
||||
// Exact match for non-hidden dirs
|
||||
if dirName == ignored {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// actionFileWalker encapsulates the logic for walking directories and finding action files.
|
||||
type actionFileWalker struct {
|
||||
ignoredDirs []string
|
||||
actionFiles []string
|
||||
}
|
||||
|
||||
// walkFunc is the callback function for filepath.Walk.
|
||||
func (w *actionFileWalker) walkFunc(path string, info os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if info.IsDir() {
|
||||
if shouldIgnoreDirectory(info.Name(), w.ignoredDirs) {
|
||||
return filepath.SkipDir
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Check for action.yml or action.yaml files
|
||||
filename := strings.ToLower(info.Name())
|
||||
if filename == appconstants.ActionFileNameYML || filename == appconstants.ActionFileNameYAML {
|
||||
w.actionFiles = append(w.actionFiles, path)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// DiscoverActionFiles finds action.yml and action.yaml files in the given directory.
|
||||
// This consolidates the file discovery logic from both generator.go and dependencies/parser.go.
|
||||
func DiscoverActionFiles(dir string, recursive bool) ([]string, error) {
|
||||
var actionFiles []string
|
||||
|
||||
func DiscoverActionFiles(dir string, recursive bool, ignoredDirs []string) ([]string, error) {
|
||||
// Check if dir exists
|
||||
if _, err := os.Stat(dir); os.IsNotExist(err) {
|
||||
return nil, fmt.Errorf("directory does not exist: %s", dir)
|
||||
}
|
||||
|
||||
if recursive {
|
||||
err := filepath.Walk(dir, func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if info.IsDir() {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Check for action.yml or action.yaml files
|
||||
filename := strings.ToLower(info.Name())
|
||||
if filename == appconstants.ActionFileNameYML || filename == appconstants.ActionFileNameYAML {
|
||||
actionFiles = append(actionFiles, path)
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
walker := &actionFileWalker{ignoredDirs: ignoredDirs}
|
||||
if err := filepath.Walk(dir, walker.walkFunc); err != nil {
|
||||
return nil, fmt.Errorf("failed to walk directory %s: %w", dir, err)
|
||||
}
|
||||
} else {
|
||||
// Check only the specified directory
|
||||
for _, filename := range []string{appconstants.ActionFileNameYML, appconstants.ActionFileNameYAML} {
|
||||
path := filepath.Join(dir, filename)
|
||||
if _, err := os.Stat(path); err == nil {
|
||||
actionFiles = append(actionFiles, path)
|
||||
}
|
||||
|
||||
return walker.actionFiles, nil
|
||||
}
|
||||
|
||||
// Check only the specified directory (non-recursive)
|
||||
return DiscoverActionFilesNonRecursive(dir), nil
|
||||
}
|
||||
|
||||
// DiscoverActionFilesNonRecursive finds action files (action.yml or action.yaml) in a single directory.
|
||||
// This is exported for use by other packages that need to discover action files.
|
||||
func DiscoverActionFilesNonRecursive(dir string) []string {
|
||||
var actionFiles []string
|
||||
for _, filename := range []string{appconstants.ActionFileNameYML, appconstants.ActionFileNameYAML} {
|
||||
path := filepath.Join(dir, filename)
|
||||
if _, err := os.Stat(path); err == nil {
|
||||
actionFiles = append(actionFiles, path)
|
||||
}
|
||||
}
|
||||
|
||||
return actionFiles, nil
|
||||
return actionFiles
|
||||
}
|
||||
|
||||
690
internal/parser_mutation_test.go
Normal file
690
internal/parser_mutation_test.go
Normal file
@@ -0,0 +1,690 @@
|
||||
package internal
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/ivuorinen/gh-action-readme/testutil"
|
||||
)
|
||||
|
||||
// TestPermissionParsingMutationResistance provides comprehensive test cases designed
|
||||
// to catch mutations in the permission parsing logic. These tests target critical
|
||||
// boundaries, operators, and conditions that are susceptible to mutation.
|
||||
//
|
||||
// permissionParsingTestCase defines a test case for permission parsing tests.
|
||||
type permissionParsingTestCase struct {
|
||||
name string
|
||||
yaml string
|
||||
expected map[string]string
|
||||
critical bool
|
||||
}
|
||||
|
||||
// buildPermissionParsingTestCases returns all test cases for permission parsing.
|
||||
// YAML content is loaded from fixture files in testdata/yaml-fixtures/configs/permissions/mutation/.
|
||||
func buildPermissionParsingTestCases() []permissionParsingTestCase {
|
||||
const fixtureDir = "configs/permissions/mutation/"
|
||||
|
||||
return []permissionParsingTestCase{
|
||||
{
|
||||
name: "off_by_one_indent_two_items",
|
||||
yaml: testutil.MustReadFixture(fixtureDir + "off-by-one-indent-two-items.yaml"),
|
||||
expected: map[string]string{"contents": "read", "issues": "write"},
|
||||
critical: true,
|
||||
},
|
||||
{
|
||||
name: "off_by_one_indent_three_items",
|
||||
yaml: testutil.MustReadFixture(fixtureDir + "off-by-one-indent-three-items.yaml"),
|
||||
expected: map[string]string{
|
||||
"contents": "read",
|
||||
"issues": "write",
|
||||
testutil.TestFixturePullRequests: "read",
|
||||
},
|
||||
critical: true,
|
||||
},
|
||||
{
|
||||
name: "comment_position_at_boundary",
|
||||
yaml: testutil.MustReadFixture(fixtureDir + "comment-position-at-boundary.yaml"),
|
||||
expected: map[string]string{"contents": "read"},
|
||||
critical: true,
|
||||
},
|
||||
{
|
||||
name: "comment_at_position_zero_parses",
|
||||
yaml: testutil.MustReadFixture(fixtureDir + "comment-at-position-zero-parses.yaml"),
|
||||
expected: map[string]string{"contents": "read"},
|
||||
critical: true,
|
||||
},
|
||||
{
|
||||
name: "dash_prefix_with_spaces",
|
||||
yaml: testutil.MustReadFixture(fixtureDir + "dash-prefix-with-spaces.yaml"),
|
||||
expected: map[string]string{"contents": "read", "issues": "write"},
|
||||
critical: true,
|
||||
},
|
||||
{
|
||||
name: "mixed_dash_and_no_dash",
|
||||
yaml: testutil.MustReadFixture(fixtureDir + "mixed-dash-and-no-dash.yaml"),
|
||||
expected: map[string]string{"contents": "read", "issues": "write"},
|
||||
critical: true,
|
||||
},
|
||||
{
|
||||
name: "dedent_stops_parsing",
|
||||
yaml: testutil.MustReadFixture(fixtureDir + "dedent-stops-parsing.yaml"),
|
||||
expected: map[string]string{"contents": "read"},
|
||||
critical: true,
|
||||
},
|
||||
{
|
||||
name: "empty_line_in_block_continues",
|
||||
yaml: testutil.MustReadFixture(fixtureDir + "empty-line-in-block-continues.yaml"),
|
||||
expected: map[string]string{"contents": "read", "issues": "write"},
|
||||
critical: false,
|
||||
},
|
||||
{
|
||||
name: "non_comment_line_stops_parsing",
|
||||
yaml: testutil.MustReadFixture(fixtureDir + "non-comment-line-stops-parsing.yaml"),
|
||||
expected: map[string]string{"contents": "read"},
|
||||
critical: true,
|
||||
},
|
||||
{
|
||||
name: "exact_expected_indent",
|
||||
yaml: testutil.MustReadFixture(fixtureDir + "exact-expected-indent.yaml"),
|
||||
expected: map[string]string{"contents": "read"},
|
||||
critical: true,
|
||||
},
|
||||
{
|
||||
name: "colon_in_value_preserved",
|
||||
yaml: testutil.MustReadFixture(fixtureDir + "colon-in-value-preserved.yaml"),
|
||||
expected: map[string]string{"contents": "read:write"},
|
||||
critical: true,
|
||||
},
|
||||
{
|
||||
name: "empty_key_not_parsed",
|
||||
yaml: testutil.MustReadFixture(fixtureDir + "empty-key-not-parsed.yaml"),
|
||||
expected: map[string]string{},
|
||||
critical: true,
|
||||
},
|
||||
{
|
||||
name: "empty_value_not_parsed",
|
||||
yaml: testutil.MustReadFixture(fixtureDir + "empty-value-not-parsed.yaml"),
|
||||
expected: map[string]string{},
|
||||
critical: true,
|
||||
},
|
||||
{
|
||||
name: "whitespace_only_value_not_parsed",
|
||||
yaml: testutil.MustReadFixture(fixtureDir + "whitespace-only-value-not-parsed.yaml"),
|
||||
expected: map[string]string{},
|
||||
critical: true,
|
||||
},
|
||||
{
|
||||
name: "multiple_colons_splits_at_first",
|
||||
yaml: testutil.MustReadFixture(fixtureDir + "multiple-colons-splits-at-first.yaml"),
|
||||
expected: map[string]string{"url": "https://example.com:8080"},
|
||||
critical: true,
|
||||
},
|
||||
{
|
||||
name: "inline_comment_removal",
|
||||
yaml: testutil.MustReadFixture(fixtureDir + "inline-comment-removal.yaml"),
|
||||
expected: map[string]string{"contents": "read"},
|
||||
critical: true,
|
||||
},
|
||||
{
|
||||
name: "inline_comment_at_start_of_value",
|
||||
yaml: testutil.MustReadFixture(fixtureDir + "inline-comment-at-start-of-value.yaml"),
|
||||
expected: map[string]string{},
|
||||
critical: true,
|
||||
},
|
||||
{
|
||||
name: "deeply_nested_indent",
|
||||
yaml: testutil.MustReadFixture(fixtureDir + "deeply-nested-indent.yaml"),
|
||||
expected: map[string]string{"contents": "read", "issues": "write"},
|
||||
critical: true,
|
||||
},
|
||||
{
|
||||
name: "minimal_valid_permission",
|
||||
yaml: testutil.MustReadFixture(fixtureDir + "minimal-valid-permission.yaml"),
|
||||
expected: map[string]string{"x": "y"},
|
||||
critical: true,
|
||||
},
|
||||
{
|
||||
name: "maximum_realistic_permissions",
|
||||
yaml: testutil.MustReadFixture(fixtureDir + "maximum-realistic-permissions.yaml"),
|
||||
expected: map[string]string{
|
||||
"actions": "write",
|
||||
"attestations": "write",
|
||||
"checks": "write",
|
||||
"contents": "write",
|
||||
"deployments": "write",
|
||||
"discussions": "write",
|
||||
"id-token": "write",
|
||||
"issues": "write",
|
||||
"packages": "write",
|
||||
"pages": "write",
|
||||
testutil.TestFixturePullRequests: "write",
|
||||
"repository-projects": "write",
|
||||
"security-events": "write",
|
||||
"statuses": "write",
|
||||
},
|
||||
critical: false,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func TestPermissionParsingMutationResistance(t *testing.T) {
|
||||
tests := buildPermissionParsingTestCases()
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
testPermissionParsingCase(t, tt.yaml, tt.expected)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func testPermissionParsingCase(t *testing.T, yaml string, expected map[string]string) {
|
||||
t.Helper()
|
||||
|
||||
// Create temporary file with test YAML
|
||||
tmpDir := t.TempDir()
|
||||
testFile := filepath.Join(tmpDir, "action.yml")
|
||||
|
||||
testutil.WriteTestFile(t, testFile, yaml)
|
||||
|
||||
// Parse permissions
|
||||
result, err := parsePermissionsFromComments(testFile)
|
||||
if err != nil {
|
||||
t.Fatalf("parsePermissionsFromComments() error = %v", err)
|
||||
}
|
||||
|
||||
// Verify expected permissions
|
||||
if len(result) != len(expected) {
|
||||
t.Errorf("got %d permissions, want %d", len(result), len(expected))
|
||||
t.Logf("got: %v", result)
|
||||
t.Logf("want: %v", expected)
|
||||
}
|
||||
|
||||
for key, expectedValue := range expected {
|
||||
gotValue, exists := result[key]
|
||||
if !exists {
|
||||
t.Errorf(testutil.TestFixtureMissingPermKey, key)
|
||||
|
||||
continue
|
||||
}
|
||||
if gotValue != expectedValue {
|
||||
t.Errorf("permission %q: got value %q, want %q", key, gotValue, expectedValue)
|
||||
}
|
||||
}
|
||||
|
||||
// Check for unexpected keys
|
||||
for key := range result {
|
||||
if _, expected := expected[key]; !expected {
|
||||
t.Errorf("unexpected permission key %q with value %q", key, result[key])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestMergePermissionsMutationResistance tests the permission merging logic
|
||||
// for mutations in nil checks, map operations, and precedence logic.
|
||||
//
|
||||
|
||||
func TestMergePermissionsMutationResistance(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
yamlPerms map[string]string
|
||||
commentPerms map[string]string
|
||||
expected map[string]string
|
||||
critical bool
|
||||
description string
|
||||
}{
|
||||
{
|
||||
name: "nil_yaml_nil_comment",
|
||||
yamlPerms: nil,
|
||||
commentPerms: nil,
|
||||
expected: nil,
|
||||
critical: true,
|
||||
description: "Both nil should stay nil (nil check critical)",
|
||||
},
|
||||
{
|
||||
name: "nil_yaml_with_comment",
|
||||
yamlPerms: nil,
|
||||
commentPerms: map[string]string{"contents": "read"},
|
||||
expected: map[string]string{"contents": "read"},
|
||||
critical: true,
|
||||
description: "Nil YAML replaced by comment perms (first condition)",
|
||||
},
|
||||
{
|
||||
name: "yaml_with_nil_comment",
|
||||
yamlPerms: map[string]string{"contents": "write"},
|
||||
commentPerms: nil,
|
||||
expected: map[string]string{"contents": "write"},
|
||||
critical: true,
|
||||
description: "Nil comment keeps YAML perms (second condition)",
|
||||
},
|
||||
{
|
||||
name: "empty_yaml_empty_comment",
|
||||
yamlPerms: map[string]string{},
|
||||
commentPerms: map[string]string{},
|
||||
expected: map[string]string{},
|
||||
critical: true,
|
||||
description: "Both empty should stay empty",
|
||||
},
|
||||
{
|
||||
name: "yaml_overrides_comment_same_key",
|
||||
yamlPerms: map[string]string{"contents": "write"},
|
||||
commentPerms: map[string]string{"contents": "read"},
|
||||
expected: map[string]string{"contents": "write"},
|
||||
critical: true,
|
||||
description: "YAML value wins conflict (exists check critical)",
|
||||
},
|
||||
{
|
||||
name: "non_conflicting_keys_merged",
|
||||
yamlPerms: map[string]string{"contents": "write"},
|
||||
commentPerms: map[string]string{"issues": "read"},
|
||||
expected: map[string]string{"contents": "write", "issues": "read"},
|
||||
critical: true,
|
||||
description: "Non-conflicting keys both included",
|
||||
},
|
||||
{
|
||||
name: "multiple_yaml_override_multiple_comment",
|
||||
yamlPerms: map[string]string{
|
||||
"contents": "write",
|
||||
"issues": "write",
|
||||
},
|
||||
commentPerms: map[string]string{
|
||||
"contents": "read",
|
||||
testutil.TestFixturePullRequests: "read",
|
||||
},
|
||||
expected: map[string]string{
|
||||
"contents": "write", // YAML wins
|
||||
"issues": "write", // Only in YAML
|
||||
testutil.TestFixturePullRequests: "read", // Only in comment
|
||||
},
|
||||
critical: true,
|
||||
description: "Complex merge with conflicts and unique keys",
|
||||
},
|
||||
{
|
||||
name: "single_key_conflict",
|
||||
yamlPerms: map[string]string{"x": "a"},
|
||||
commentPerms: map[string]string{"x": "b"},
|
||||
expected: map[string]string{"x": "a"},
|
||||
critical: true,
|
||||
description: "Minimal conflict test (YAML precedence)",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
testMergePermissionsCase(t, tt.yamlPerms, tt.commentPerms, tt.expected, tt.description)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func testMergePermissionsCase(
|
||||
t *testing.T,
|
||||
yamlPerms, commentPerms, expected map[string]string,
|
||||
description string,
|
||||
) {
|
||||
t.Helper()
|
||||
|
||||
// Create ActionYML with test permissions
|
||||
action := &ActionYML{
|
||||
Permissions: copyStringMap(yamlPerms),
|
||||
}
|
||||
|
||||
// Copy commentPerms to avoid mutation during test
|
||||
commentPermsCopy := copyStringMap(commentPerms)
|
||||
|
||||
// Perform merge
|
||||
mergePermissions(action, commentPermsCopy)
|
||||
|
||||
// Verify result
|
||||
assertPermissionsMatch(t, action.Permissions, expected, description)
|
||||
}
|
||||
|
||||
// copyStringMap creates a deep copy of a string map, returning nil for nil input.
|
||||
func copyStringMap(input map[string]string) map[string]string {
|
||||
if input == nil {
|
||||
return nil
|
||||
}
|
||||
result := make(map[string]string, len(input))
|
||||
for k, v := range input {
|
||||
result[k] = v
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// assertPermissionsMatch verifies that got permissions match expected permissions.
|
||||
func assertPermissionsMatch(
|
||||
t *testing.T,
|
||||
got, want map[string]string,
|
||||
description string,
|
||||
) {
|
||||
t.Helper()
|
||||
|
||||
if want == nil {
|
||||
if got != nil {
|
||||
t.Errorf("expected nil permissions, got %v", got)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if got == nil {
|
||||
t.Errorf("expected non-nil permissions %v, got nil", want)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if len(got) != len(want) {
|
||||
t.Errorf("got %d permissions, want %d", len(got), len(want))
|
||||
t.Logf("got: %v", got)
|
||||
t.Logf("want: %v", want)
|
||||
}
|
||||
|
||||
for key, expectedValue := range want {
|
||||
gotValue, exists := got[key]
|
||||
if !exists {
|
||||
t.Errorf(testutil.TestFixtureMissingPermKey, key)
|
||||
|
||||
continue
|
||||
}
|
||||
if gotValue != expectedValue {
|
||||
t.Errorf("permission %q: got %q, want %q (description: %s)",
|
||||
key, gotValue, expectedValue, description)
|
||||
}
|
||||
}
|
||||
|
||||
for key := range got {
|
||||
if _, expected := want[key]; !expected {
|
||||
t.Errorf("unexpected permission key %q", key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// permissionLineTestCase defines a test case for parsePermissionLine tests.
|
||||
type permissionLineTestCase struct {
|
||||
name string
|
||||
content string
|
||||
expectKey string
|
||||
expectValue string
|
||||
expectOk bool
|
||||
critical bool
|
||||
description string
|
||||
}
|
||||
|
||||
// parseFailCase creates a test case expecting parse failure with empty results.
|
||||
func parseFailCase(name, content, description string) permissionLineTestCase {
|
||||
return permissionLineTestCase{
|
||||
name: name,
|
||||
content: content,
|
||||
expectKey: "",
|
||||
expectValue: "",
|
||||
expectOk: false,
|
||||
critical: true,
|
||||
description: description,
|
||||
}
|
||||
}
|
||||
|
||||
// TestParsePermissionLineMutationResistance tests string manipulation boundaries
|
||||
// in permission line parsing that are susceptible to mutation.
|
||||
//
|
||||
|
||||
func TestParsePermissionLineMutationResistance(t *testing.T) {
|
||||
tests := []permissionLineTestCase{
|
||||
{
|
||||
name: "basic_key_value",
|
||||
content: testutil.TestFixtureContentsRead,
|
||||
expectKey: "contents",
|
||||
expectValue: "read",
|
||||
expectOk: true,
|
||||
critical: true,
|
||||
description: "Basic parsing",
|
||||
},
|
||||
{
|
||||
name: "with_leading_dash",
|
||||
content: "- contents: read",
|
||||
expectKey: "contents",
|
||||
expectValue: "read",
|
||||
expectOk: true,
|
||||
critical: true,
|
||||
description: "TrimPrefix(\"-\") critical",
|
||||
},
|
||||
{
|
||||
name: "with_inline_comment_at_position_1",
|
||||
content: "contents: r#comment",
|
||||
expectKey: "contents",
|
||||
expectValue: "r",
|
||||
expectOk: true,
|
||||
critical: true,
|
||||
description: "Index() > 0 boundary (idx=10)",
|
||||
},
|
||||
// Failure test cases with empty expected results
|
||||
parseFailCase(
|
||||
"inline_comment_at_position_0_of_value",
|
||||
"contents: #read",
|
||||
"Index() at position 0 in value (should fail parse)",
|
||||
),
|
||||
{
|
||||
name: "comment_in_middle_of_line",
|
||||
content: "contents: read # Required",
|
||||
expectKey: "contents",
|
||||
expectValue: "read",
|
||||
expectOk: true,
|
||||
critical: true,
|
||||
description: "Comment removal before parse",
|
||||
},
|
||||
parseFailCase("no_colon", "contents read", "len(parts) == 2 check"),
|
||||
{
|
||||
name: "multiple_colons",
|
||||
content: "url: https://example.com:8080",
|
||||
expectKey: "url",
|
||||
expectValue: "https://example.com:8080",
|
||||
expectOk: true,
|
||||
critical: true,
|
||||
description: "SplitN with n=2 preserves colons in value",
|
||||
},
|
||||
parseFailCase("empty_key", ": value", "key != \"\" check critical"),
|
||||
parseFailCase("empty_value", "key:", "value != \"\" check critical"),
|
||||
parseFailCase("whitespace_key", " : value", "TrimSpace on key critical"),
|
||||
parseFailCase("whitespace_value", "key: ", "TrimSpace on value critical"),
|
||||
{
|
||||
name: "single_char_key_value",
|
||||
content: "a: b",
|
||||
expectKey: "a",
|
||||
expectValue: "b",
|
||||
expectOk: true,
|
||||
critical: true,
|
||||
description: "Minimal valid case",
|
||||
},
|
||||
{
|
||||
name: "colon_in_key_should_not_happen",
|
||||
content: "key:name: value",
|
||||
expectKey: "key",
|
||||
expectValue: "name: value",
|
||||
expectOk: true,
|
||||
critical: false,
|
||||
description: "First colon splits (malformed input)",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
testParsePermissionLineCase(
|
||||
t,
|
||||
tt.content,
|
||||
tt.expectKey,
|
||||
tt.expectValue,
|
||||
tt.expectOk,
|
||||
tt.description,
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func testParsePermissionLineCase(
|
||||
t *testing.T,
|
||||
content, expectKey, expectValue string,
|
||||
expectOk bool,
|
||||
description string,
|
||||
) {
|
||||
t.Helper()
|
||||
|
||||
key, value, ok := parsePermissionLine(content)
|
||||
|
||||
if ok != expectOk {
|
||||
t.Errorf("ok: got %v, want %v (description: %s)", ok, expectOk, description)
|
||||
}
|
||||
|
||||
if ok {
|
||||
if key != expectKey {
|
||||
t.Errorf("key: got %q, want %q (description: %s)", key, expectKey, description)
|
||||
}
|
||||
if value != expectValue {
|
||||
t.Errorf("value: got %q, want %q (description: %s)", value, expectValue, description)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestProcessPermissionEntryMutationResistance tests indentation logic that is
|
||||
// highly susceptible to off-by-one mutations.
|
||||
//
|
||||
|
||||
func TestProcessPermissionEntryMutationResistance(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
line string
|
||||
content string
|
||||
initialExpected int
|
||||
expectBreak bool
|
||||
expectPermissions map[string]string
|
||||
critical bool
|
||||
description string
|
||||
}{
|
||||
{
|
||||
name: "first_item_sets_indent",
|
||||
line: "# contents: read",
|
||||
content: testutil.TestFixtureContentsRead,
|
||||
initialExpected: -1,
|
||||
expectBreak: false,
|
||||
expectPermissions: map[string]string{"contents": "read"},
|
||||
critical: true,
|
||||
description: "*expectedItemIndent == -1 check",
|
||||
},
|
||||
{
|
||||
name: "same_indent_continues",
|
||||
line: "# issues: write",
|
||||
content: testutil.TestFixtureIssuesWrite,
|
||||
initialExpected: 3,
|
||||
expectBreak: false,
|
||||
expectPermissions: map[string]string{"issues": "write"},
|
||||
critical: true,
|
||||
description: "contentIndent == expectedItemIndent",
|
||||
},
|
||||
{
|
||||
name: "dedent_by_one_breaks",
|
||||
line: "# issues: write",
|
||||
content: testutil.TestFixtureIssuesWrite,
|
||||
initialExpected: 3,
|
||||
expectBreak: true,
|
||||
expectPermissions: map[string]string{},
|
||||
critical: true,
|
||||
description: "contentIndent < expectedItemIndent (2 < 3)",
|
||||
},
|
||||
{
|
||||
name: "dedent_by_two_breaks",
|
||||
line: "# issues: write",
|
||||
content: testutil.TestFixtureIssuesWrite,
|
||||
initialExpected: 3,
|
||||
expectBreak: true,
|
||||
expectPermissions: map[string]string{},
|
||||
critical: true,
|
||||
description: "contentIndent < expectedItemIndent (0 < 3)",
|
||||
},
|
||||
{
|
||||
name: "indent_more_continues",
|
||||
line: "# issues: write",
|
||||
content: testutil.TestFixtureIssuesWrite,
|
||||
initialExpected: 3,
|
||||
expectBreak: false,
|
||||
expectPermissions: map[string]string{"issues": "write"},
|
||||
critical: false,
|
||||
description: "More indent allowed (unusual but valid)",
|
||||
},
|
||||
{
|
||||
name: "zero_indent_with_zero_expected",
|
||||
line: "# contents: read",
|
||||
content: testutil.TestFixtureContentsRead,
|
||||
initialExpected: 0,
|
||||
expectBreak: false,
|
||||
expectPermissions: map[string]string{"contents": "read"},
|
||||
critical: true,
|
||||
description: "Boundary: 0 == 0",
|
||||
},
|
||||
{
|
||||
name: "large_indent_value",
|
||||
line: "# contents: read",
|
||||
content: testutil.TestFixtureContentsRead,
|
||||
initialExpected: -1,
|
||||
expectBreak: false,
|
||||
expectPermissions: map[string]string{"contents": "read"},
|
||||
critical: false,
|
||||
description: "Large indent value (10 spaces)",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
testProcessPermissionEntryCase(
|
||||
t,
|
||||
tt.line,
|
||||
tt.content,
|
||||
tt.initialExpected,
|
||||
tt.expectBreak,
|
||||
tt.expectPermissions,
|
||||
tt.description,
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func testProcessPermissionEntryCase(
|
||||
t *testing.T,
|
||||
line, content string,
|
||||
initialExpected int,
|
||||
expectBreak bool,
|
||||
expectPermissions map[string]string,
|
||||
description string,
|
||||
) {
|
||||
t.Helper()
|
||||
|
||||
permissions := make(map[string]string)
|
||||
expectedIndent := initialExpected
|
||||
|
||||
shouldBreak := processPermissionEntry(line, content, &expectedIndent, permissions)
|
||||
|
||||
if shouldBreak != expectBreak {
|
||||
t.Errorf("shouldBreak: got %v, want %v (description: %s)",
|
||||
shouldBreak, expectBreak, description)
|
||||
}
|
||||
|
||||
if len(permissions) != len(expectPermissions) {
|
||||
t.Errorf("got %d permissions, want %d (description: %s)",
|
||||
len(permissions), len(expectPermissions), description)
|
||||
}
|
||||
|
||||
for key, expectedValue := range expectPermissions {
|
||||
gotValue, exists := permissions[key]
|
||||
if !exists {
|
||||
t.Errorf(testutil.TestFixtureMissingPermKey, key)
|
||||
|
||||
continue
|
||||
}
|
||||
if gotValue != expectedValue {
|
||||
t.Errorf("permission %q: got %q, want %q", key, gotValue, expectedValue)
|
||||
}
|
||||
}
|
||||
|
||||
// Verify expected indent was set if it was -1
|
||||
if initialExpected == -1 && len(expectPermissions) > 0 {
|
||||
if expectedIndent == -1 {
|
||||
t.Error("expectedIndent should have been set from -1")
|
||||
}
|
||||
}
|
||||
}
|
||||
269
internal/parser_property_test.go
Normal file
269
internal/parser_property_test.go
Normal file
@@ -0,0 +1,269 @@
|
||||
package internal
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/leanovate/gopter"
|
||||
"github.com/leanovate/gopter/gen"
|
||||
"github.com/leanovate/gopter/prop"
|
||||
)
|
||||
|
||||
// TestPermissionMergingProperties verifies properties of permission merging.
|
||||
func TestPermissionMergingProperties(t *testing.T) {
|
||||
properties := gopter.NewProperties(nil)
|
||||
registerPermissionMergingProperties(properties)
|
||||
properties.TestingRun(t)
|
||||
}
|
||||
|
||||
// registerPermissionMergingProperties registers all permission merging property tests.
|
||||
func registerPermissionMergingProperties(properties *gopter.Properties) {
|
||||
registerYAMLOverridesProperty(properties)
|
||||
registerNonConflictingKeysProperty(properties)
|
||||
registerNilPreservesOriginalProperty(properties)
|
||||
registerEmptyMapPreservesOriginalProperty(properties)
|
||||
registerResultSizeBoundedProperty(properties)
|
||||
}
|
||||
|
||||
// registerYAMLOverridesProperty tests that YAML permissions override comment permissions.
|
||||
func registerYAMLOverridesProperty(properties *gopter.Properties) {
|
||||
properties.Property("YAML permissions override comment permissions",
|
||||
prop.ForAll(
|
||||
func(key, yamlVal, commentVal string) bool {
|
||||
if yamlVal == commentVal || yamlVal == "" || key == "" || commentVal == "" {
|
||||
return true
|
||||
}
|
||||
action := &ActionYML{Permissions: map[string]string{key: yamlVal}}
|
||||
mergePermissions(action, map[string]string{key: commentVal})
|
||||
|
||||
return action.Permissions[key] == yamlVal
|
||||
},
|
||||
gen.AlphaString().SuchThat(func(s string) bool { return s != "" }),
|
||||
gen.AlphaString().SuchThat(func(s string) bool { return s != "" }),
|
||||
gen.AlphaString().SuchThat(func(s string) bool { return s != "" }),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
// registerNonConflictingKeysProperty tests that non-conflicting keys are preserved.
|
||||
func registerNonConflictingKeysProperty(properties *gopter.Properties) {
|
||||
properties.Property("merge preserves all non-conflicting keys",
|
||||
prop.ForAll(
|
||||
func(yamlKey, commentKey, val string) bool {
|
||||
if yamlKey == commentKey || yamlKey == "" || commentKey == "" || val == "" {
|
||||
return true
|
||||
}
|
||||
action := &ActionYML{Permissions: map[string]string{yamlKey: val}}
|
||||
mergePermissions(action, map[string]string{commentKey: val})
|
||||
_, hasYaml := action.Permissions[yamlKey]
|
||||
_, hasComment := action.Permissions[commentKey]
|
||||
|
||||
return hasYaml && hasComment
|
||||
},
|
||||
gen.AlphaString().SuchThat(func(s string) bool { return s != "" }),
|
||||
gen.AlphaString().SuchThat(func(s string) bool { return s != "" }),
|
||||
gen.AlphaString().SuchThat(func(s string) bool { return s != "" }),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
// registerNilPreservesOriginalProperty tests merging with nil preserves original.
|
||||
func registerNilPreservesOriginalProperty(properties *gopter.Properties) {
|
||||
properties.Property("merging with nil preserves original permissions",
|
||||
prop.ForAll(
|
||||
func(key, value string) bool {
|
||||
return verifyMergePreservesOriginal(key, value, nil)
|
||||
},
|
||||
gen.AlphaString().SuchThat(func(s string) bool { return s != "" }),
|
||||
gen.AlphaString().SuchThat(func(s string) bool { return s != "" }),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
// registerEmptyMapPreservesOriginalProperty tests merging with empty map preserves original.
|
||||
func registerEmptyMapPreservesOriginalProperty(properties *gopter.Properties) {
|
||||
properties.Property("merging with empty map preserves original permissions",
|
||||
prop.ForAll(
|
||||
func(key, value string) bool {
|
||||
return verifyMergePreservesOriginal(key, value, make(map[string]string))
|
||||
},
|
||||
gen.AlphaString().SuchThat(func(s string) bool { return s != "" }),
|
||||
gen.AlphaString().SuchThat(func(s string) bool { return s != "" }),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
// registerResultSizeBoundedProperty tests result size is bounded by sum of inputs.
|
||||
func registerResultSizeBoundedProperty(properties *gopter.Properties) {
|
||||
properties.Property("merged permissions size bounded by sum of inputs",
|
||||
prop.ForAll(
|
||||
verifyMergedSizeBounded,
|
||||
gen.SliceOf(gen.AlphaString()),
|
||||
gen.SliceOf(gen.AlphaString()),
|
||||
gen.AlphaString().SuchThat(func(s string) bool { return s != "" }),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
// verifyMergedSizeBounded checks that merged result size is bounded.
|
||||
func verifyMergedSizeBounded(yamlKeys, commentKeys []string, value string) bool {
|
||||
if len(yamlKeys) == 0 || len(commentKeys) == 0 || value == "" {
|
||||
return true
|
||||
}
|
||||
yamlPerms := make(map[string]string)
|
||||
for _, key := range yamlKeys {
|
||||
if key != "" {
|
||||
yamlPerms[key] = value
|
||||
}
|
||||
}
|
||||
commentPerms := make(map[string]string)
|
||||
for _, key := range commentKeys {
|
||||
if key != "" {
|
||||
commentPerms[key] = value
|
||||
}
|
||||
}
|
||||
action := &ActionYML{Permissions: yamlPerms}
|
||||
mergePermissions(action, commentPerms)
|
||||
|
||||
return len(action.Permissions) <= len(yamlPerms)+len(commentPerms)
|
||||
}
|
||||
|
||||
// TestActionYMLNilPermissionsProperties verifies behavior when permissions is nil.
|
||||
func TestActionYMLNilPermissionsProperties(t *testing.T) {
|
||||
properties := gopter.NewProperties(nil)
|
||||
|
||||
// Property 1: Merging into nil permissions creates new map
|
||||
properties.Property("merging into nil permissions creates new map",
|
||||
prop.ForAll(
|
||||
func(key, value string) bool {
|
||||
if key == "" || value == "" {
|
||||
return true
|
||||
}
|
||||
|
||||
action := &ActionYML{
|
||||
Permissions: nil,
|
||||
}
|
||||
|
||||
commentPerms := map[string]string{key: value}
|
||||
mergePermissions(action, commentPerms)
|
||||
|
||||
// Should create new map with comment permissions
|
||||
if action.Permissions == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
return action.Permissions[key] == value
|
||||
},
|
||||
gen.AlphaString().SuchThat(func(s string) bool { return s != "" }),
|
||||
gen.AlphaString().SuchThat(func(s string) bool { return s != "" }),
|
||||
),
|
||||
)
|
||||
|
||||
// Property 2: Nil action permissions stays nil when merging with nil
|
||||
properties.Property("nil permissions stays nil when merging with nil",
|
||||
prop.ForAll(
|
||||
func() bool {
|
||||
action := &ActionYML{
|
||||
Permissions: nil,
|
||||
}
|
||||
|
||||
mergePermissions(action, nil)
|
||||
|
||||
// Should remain nil (no map created)
|
||||
return action.Permissions == nil
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
properties.TestingRun(t)
|
||||
}
|
||||
|
||||
// TestCommentPermissionsOnlyProperties verifies behavior when only comment permissions exist.
|
||||
//
|
||||
|
||||
func TestCommentPermissionsOnlyProperties(t *testing.T) {
|
||||
properties := gopter.NewProperties(nil)
|
||||
registerCommentPermissionsOnlyProperties(properties)
|
||||
properties.TestingRun(t)
|
||||
}
|
||||
|
||||
func registerCommentPermissionsOnlyProperties(properties *gopter.Properties) {
|
||||
// Property: All comment permissions transferred when YAML is nil
|
||||
properties.Property("all comment permissions transferred when YAML is nil",
|
||||
prop.ForAll(
|
||||
verifyCommentPermissionsTransferred,
|
||||
gen.SliceOf(gen.AlphaString().SuchThat(func(s string) bool { return s != "" })),
|
||||
gen.AlphaString().SuchThat(func(s string) bool { return s != "" }),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
func verifyCommentPermissionsTransferred(keys []string, value string) bool {
|
||||
if len(keys) == 0 || value == "" {
|
||||
return true
|
||||
}
|
||||
|
||||
// Build comment permissions
|
||||
commentPerms := make(map[string]string)
|
||||
for _, key := range keys {
|
||||
if key != "" {
|
||||
commentPerms[key] = value
|
||||
}
|
||||
}
|
||||
|
||||
if len(commentPerms) == 0 {
|
||||
return true
|
||||
}
|
||||
|
||||
action := &ActionYML{
|
||||
Permissions: nil,
|
||||
}
|
||||
|
||||
mergePermissions(action, commentPerms)
|
||||
|
||||
// All comment permissions should be in action
|
||||
if action.Permissions == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
for key, val := range commentPerms {
|
||||
if action.Permissions[key] != val {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// verifyMergePreservesOriginal is a helper to test that merging with
|
||||
// nil or empty permissions preserves the original permissions.
|
||||
func verifyMergePreservesOriginal(key, value string, mergeWith map[string]string) bool {
|
||||
if key == "" || value == "" {
|
||||
return true
|
||||
}
|
||||
|
||||
action := &ActionYML{
|
||||
Permissions: map[string]string{key: value},
|
||||
}
|
||||
|
||||
// Make a copy to compare
|
||||
originalPerms := make(map[string]string)
|
||||
for k, v := range action.Permissions {
|
||||
originalPerms[k] = v
|
||||
}
|
||||
|
||||
// Merge with provided map (nil or empty)
|
||||
mergePermissions(action, mergeWith)
|
||||
|
||||
// Should be unchanged
|
||||
if len(action.Permissions) != len(originalPerms) {
|
||||
return false
|
||||
}
|
||||
|
||||
for k, v := range originalPerms {
|
||||
if action.Permissions[k] != v {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
787
internal/parser_test.go
Normal file
787
internal/parser_test.go
Normal file
@@ -0,0 +1,787 @@
|
||||
package internal
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/ivuorinen/gh-action-readme/appconstants"
|
||||
"github.com/ivuorinen/gh-action-readme/testutil"
|
||||
)
|
||||
|
||||
const testPermissionWrite = "write"
|
||||
|
||||
// parseActionFromContent creates a temporary action.yml file with the given content and parses it.
|
||||
func parseActionFromContent(t *testing.T, content string) (*ActionYML, error) {
|
||||
t.Helper()
|
||||
|
||||
actionPath := testutil.CreateTempActionFile(t, content)
|
||||
|
||||
return ParseActionYML(actionPath)
|
||||
}
|
||||
|
||||
// validateDiscoveredFiles checks if discovered files match expected count and paths.
|
||||
func validateDiscoveredFiles(t *testing.T, files []string, wantCount int, wantPaths []string) {
|
||||
t.Helper()
|
||||
|
||||
if len(files) != wantCount {
|
||||
t.Errorf("DiscoverActionFiles() returned %d files, want %d", len(files), wantCount)
|
||||
t.Logf("Got files: %v", files)
|
||||
t.Logf("Want files: %v", wantPaths)
|
||||
}
|
||||
|
||||
// Check that all expected files are present
|
||||
fileMap := make(map[string]bool)
|
||||
for _, f := range files {
|
||||
fileMap[f] = true
|
||||
}
|
||||
|
||||
for _, wantPath := range wantPaths {
|
||||
if !fileMap[wantPath] {
|
||||
t.Errorf("Expected file %s not found in results", wantPath)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestShouldIgnoreDirectory tests the directory filtering logic.
|
||||
func TestShouldIgnoreDirectory(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
dirName string
|
||||
ignoredDirs []string
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
name: "exact match - node_modules",
|
||||
dirName: appconstants.DirNodeModules,
|
||||
ignoredDirs: []string{appconstants.DirNodeModules, appconstants.DirVendor},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "exact match - vendor",
|
||||
dirName: appconstants.DirVendor,
|
||||
ignoredDirs: []string{appconstants.DirNodeModules, appconstants.DirVendor},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: testutil.TestCaseNameNoMatch,
|
||||
dirName: "src",
|
||||
ignoredDirs: []string{appconstants.DirNodeModules, appconstants.DirVendor},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "empty ignore list",
|
||||
dirName: appconstants.DirNodeModules,
|
||||
ignoredDirs: []string{},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "dot prefix match - .git",
|
||||
dirName: appconstants.DirGit,
|
||||
ignoredDirs: []string{appconstants.DirGit},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "dot prefix pattern match - .github",
|
||||
dirName: appconstants.DirGitHub,
|
||||
ignoredDirs: []string{appconstants.DirGit},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "dot prefix pattern match - .gitlab",
|
||||
dirName: appconstants.DirGitLab,
|
||||
ignoredDirs: []string{appconstants.DirGit},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "dot prefix no match",
|
||||
dirName: ".config",
|
||||
ignoredDirs: []string{appconstants.DirGit},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "case sensitive - NODE_MODULES vs node_modules",
|
||||
dirName: "NODE_MODULES",
|
||||
ignoredDirs: []string{appconstants.DirNodeModules},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "partial name not matched",
|
||||
dirName: "my_vendor",
|
||||
ignoredDirs: []string{appconstants.DirVendor},
|
||||
want: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := shouldIgnoreDirectory(tt.dirName, tt.ignoredDirs)
|
||||
if got != tt.want {
|
||||
t.Errorf("shouldIgnoreDirectory(%q, %v) = %v, want %v",
|
||||
tt.dirName, tt.ignoredDirs, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestDiscoverActionFilesWithIgnoredDirectories tests file discovery with directory filtering.
|
||||
func TestDiscoverActionFilesWithIgnoredDirectories(t *testing.T) {
|
||||
// Create temporary directory structure
|
||||
tmpDir := t.TempDir()
|
||||
|
||||
// Create directory structure:
|
||||
// tmpDir/
|
||||
// action.yml (should be found)
|
||||
// node_modules/
|
||||
// action.yml (should be ignored)
|
||||
// vendor/
|
||||
// action.yml (should be ignored)
|
||||
// .git/
|
||||
// action.yml (should be ignored)
|
||||
// src/
|
||||
// action.yml (should be found)
|
||||
|
||||
// Create root action.yml
|
||||
rootAction := testutil.WriteFileInDir(t, tmpDir, appconstants.ActionFileNameYML, testutil.TestYAMLRoot)
|
||||
|
||||
// Create directories with action.yml files
|
||||
_, nodeModulesAction := testutil.CreateNestedAction(
|
||||
t,
|
||||
tmpDir,
|
||||
appconstants.DirNodeModules,
|
||||
testutil.TestYAMLNodeModules,
|
||||
)
|
||||
_, vendorAction := testutil.CreateNestedAction(t, tmpDir, appconstants.DirVendor, testutil.TestYAMLVendor)
|
||||
_, gitAction := testutil.CreateNestedAction(t, tmpDir, appconstants.DirGit, testutil.TestYAMLGit)
|
||||
_, srcAction := testutil.CreateNestedAction(t, tmpDir, "src", testutil.TestYAMLSrc)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
ignoredDirs []string
|
||||
wantCount int
|
||||
wantPaths []string
|
||||
}{
|
||||
{
|
||||
name: "with default ignore list",
|
||||
ignoredDirs: []string{appconstants.DirGit, appconstants.DirNodeModules, appconstants.DirVendor},
|
||||
wantCount: 2,
|
||||
wantPaths: []string{rootAction, srcAction},
|
||||
},
|
||||
{
|
||||
name: "with empty ignore list",
|
||||
ignoredDirs: []string{},
|
||||
wantCount: 5,
|
||||
wantPaths: []string{rootAction, gitAction, nodeModulesAction, srcAction, vendorAction},
|
||||
},
|
||||
{
|
||||
name: "ignore only node_modules",
|
||||
ignoredDirs: []string{appconstants.DirNodeModules},
|
||||
wantCount: 4,
|
||||
wantPaths: []string{rootAction, gitAction, srcAction, vendorAction},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
files, err := DiscoverActionFiles(tmpDir, true, tt.ignoredDirs)
|
||||
if err != nil {
|
||||
t.Fatalf(testutil.ErrDiscoverActionFiles(), err)
|
||||
}
|
||||
|
||||
validateDiscoveredFiles(t, files, tt.wantCount, tt.wantPaths)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestDiscoverActionFilesNestedIgnoredDirs tests that subdirectories of ignored dirs are skipped.
|
||||
func TestDiscoverActionFilesNestedIgnoredDirs(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
|
||||
// Create directory structure:
|
||||
// tmpDir/
|
||||
// node_modules/
|
||||
// deep/
|
||||
// nested/
|
||||
// action.yml (should be ignored)
|
||||
|
||||
nodeModulesDir := testutil.CreateTestSubdir(t, tmpDir, appconstants.DirNodeModules, "deep", "nested")
|
||||
|
||||
testutil.WriteFileInDir(t, nodeModulesDir, appconstants.ActionFileNameYML, testutil.TestYAMLNested)
|
||||
|
||||
files, err := DiscoverActionFiles(tmpDir, true, []string{appconstants.DirNodeModules})
|
||||
if err != nil {
|
||||
t.Fatalf(testutil.ErrDiscoverActionFiles(), err)
|
||||
}
|
||||
|
||||
if len(files) != 0 {
|
||||
t.Errorf("DiscoverActionFiles() returned %d files, want 0 (nested dirs should be skipped)", len(files))
|
||||
t.Logf("Got files: %v", files)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDiscoverActionFilesNonRecursive tests that non-recursive mode ignores the filter.
|
||||
func TestDiscoverActionFilesNonRecursive(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
|
||||
// Create action.yml in root
|
||||
rootAction := testutil.WriteFileInDir(t, tmpDir, appconstants.ActionFileNameYML, testutil.TestYAMLRoot)
|
||||
|
||||
// Create subdirectory (should not be searched in non-recursive mode)
|
||||
subDir := filepath.Join(tmpDir, "sub")
|
||||
if err := os.Mkdir(subDir, appconstants.FilePermDir); err != nil {
|
||||
t.Fatalf(testutil.ErrCreateDir("sub"), err)
|
||||
}
|
||||
testutil.WriteFileInDir(t, subDir, appconstants.ActionFileNameYML, testutil.TestYAMLSub)
|
||||
|
||||
files, err := DiscoverActionFiles(tmpDir, false, []string{})
|
||||
if err != nil {
|
||||
t.Fatalf(testutil.ErrDiscoverActionFiles(), err)
|
||||
}
|
||||
|
||||
if len(files) != 1 {
|
||||
t.Errorf("DiscoverActionFiles() non-recursive returned %d files, want 1", len(files))
|
||||
}
|
||||
|
||||
if len(files) > 0 && files[0] != rootAction {
|
||||
t.Errorf("DiscoverActionFiles() = %v, want %v", files[0], rootAction)
|
||||
}
|
||||
}
|
||||
|
||||
// TestParsePermissionsFromComments tests parsing permissions from header comments.
|
||||
func TestParsePermissionsFromComments(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
content string
|
||||
want map[string]string
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "single permission with dash format",
|
||||
content: string(testutil.MustReadFixture(testutil.TestFixturePermissionsDashSingle)),
|
||||
want: map[string]string{
|
||||
"contents": "read",
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "multiple permissions",
|
||||
content: string(testutil.MustReadFixture(testutil.TestFixturePermissionsDashMultiple)),
|
||||
want: map[string]string{
|
||||
"contents": "read",
|
||||
"issues": "write",
|
||||
"pull-requests": "write",
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "permissions without dash",
|
||||
content: string(testutil.MustReadFixture(testutil.TestFixturePermissionsObject)),
|
||||
want: map[string]string{
|
||||
"contents": "read",
|
||||
"issues": "write",
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "no permissions block",
|
||||
content: string(testutil.MustReadFixture(testutil.TestFixturePermissionsNone)),
|
||||
want: map[string]string{},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "permissions with inline comments",
|
||||
content: string(testutil.MustReadFixture(testutil.TestFixturePermissionsInlineComments)),
|
||||
want: map[string]string{
|
||||
"contents": "read",
|
||||
"issues": "write",
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "empty permissions block",
|
||||
content: string(testutil.MustReadFixture(testutil.TestFixturePermissionsEmpty)),
|
||||
want: map[string]string{},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "permissions with mixed formats",
|
||||
content: string(testutil.MustReadFixture(testutil.TestFixturePermissionsMixed)),
|
||||
want: map[string]string{
|
||||
"contents": "read",
|
||||
"issues": "write",
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
actionPath := testutil.CreateTempActionFile(t, tt.content)
|
||||
got, err := parsePermissionsFromComments(actionPath)
|
||||
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("parsePermissionsFromComments() error = %v, wantErr %v", err, tt.wantErr)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if !reflect.DeepEqual(got, tt.want) {
|
||||
t.Errorf("parsePermissionsFromComments() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestParseActionYMLWithCommentPermissions tests that ParseActionYML includes comment permissions.
|
||||
func TestParseActionYMLWithCommentPermissions(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
content := testutil.TestPermissionsHeader +
|
||||
"# - contents: read\n" +
|
||||
testutil.TestActionNameLine +
|
||||
testutil.TestDescriptionLine +
|
||||
testutil.TestRunsLine +
|
||||
testutil.TestCompositeUsing +
|
||||
testutil.TestStepsEmpty
|
||||
|
||||
action, err := parseActionFromContent(t, content)
|
||||
if err != nil {
|
||||
t.Fatalf(testutil.TestErrorFormat, err)
|
||||
}
|
||||
|
||||
if action.Permissions == nil {
|
||||
t.Fatal("Expected permissions to be parsed from comments")
|
||||
}
|
||||
|
||||
if action.Permissions["contents"] != "read" {
|
||||
t.Errorf("Expected contents: read, got %v", action.Permissions)
|
||||
}
|
||||
}
|
||||
|
||||
// TestParseActionYMLYAMLPermissionsOverrideComments tests that YAML permissions override comments.
|
||||
func TestParseActionYMLYAMLPermissionsOverrideComments(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
content := testutil.TestPermissionsHeader +
|
||||
"# - contents: read\n" +
|
||||
"# - issues: write\n" +
|
||||
testutil.TestActionNameLine +
|
||||
testutil.TestDescriptionLine +
|
||||
"permissions:\n" +
|
||||
" contents: write # YAML override\n" +
|
||||
testutil.TestRunsLine +
|
||||
testutil.TestCompositeUsing +
|
||||
testutil.TestStepsEmpty
|
||||
|
||||
action, err := parseActionFromContent(t, content)
|
||||
if err != nil {
|
||||
t.Fatalf(testutil.TestErrorFormat, err)
|
||||
}
|
||||
|
||||
// YAML should override comment
|
||||
if action.Permissions["contents"] != testPermissionWrite {
|
||||
t.Errorf(
|
||||
"Expected YAML permissions to override comment permissions, got contents: %v",
|
||||
action.Permissions["contents"],
|
||||
)
|
||||
}
|
||||
|
||||
// Comment permission should be merged in
|
||||
if action.Permissions["issues"] != testPermissionWrite {
|
||||
t.Errorf(
|
||||
"Expected comment permissions to be merged with YAML permissions, got issues: %v",
|
||||
action.Permissions["issues"],
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// TestParseActionYMLOnlyYAMLPermissions tests parsing when only YAML permissions exist.
|
||||
func TestParseActionYMLOnlyYAMLPermissions(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
content := testutil.TestActionNameLine +
|
||||
testutil.TestDescriptionLine +
|
||||
"permissions:\n" +
|
||||
" contents: read\n" +
|
||||
" issues: write\n" +
|
||||
testutil.TestRunsLine +
|
||||
testutil.TestCompositeUsing +
|
||||
testutil.TestStepsEmpty
|
||||
|
||||
action, err := parseActionFromContent(t, content)
|
||||
if err != nil {
|
||||
t.Fatalf(testutil.TestErrorFormat, err)
|
||||
}
|
||||
|
||||
if action.Permissions == nil {
|
||||
t.Fatal("Expected permissions to be parsed from YAML")
|
||||
}
|
||||
|
||||
if action.Permissions["contents"] != "read" {
|
||||
t.Errorf("Expected contents: read, got %v", action.Permissions)
|
||||
}
|
||||
|
||||
if action.Permissions["issues"] != testPermissionWrite {
|
||||
t.Errorf("Expected issues: write, got %v", action.Permissions)
|
||||
}
|
||||
}
|
||||
|
||||
// TestParseActionYMLNoPermissions tests parsing when no permissions exist.
|
||||
func TestParseActionYMLNoPermissions(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
content := testutil.TestActionNameLine +
|
||||
testutil.TestDescriptionLine +
|
||||
testutil.TestRunsLine +
|
||||
testutil.TestCompositeUsing +
|
||||
testutil.TestStepsEmpty
|
||||
|
||||
action, err := parseActionFromContent(t, content)
|
||||
if err != nil {
|
||||
t.Fatalf(testutil.TestErrorFormat, err)
|
||||
}
|
||||
|
||||
if action.Permissions != nil {
|
||||
t.Errorf("Expected no permissions, got %v", action.Permissions)
|
||||
}
|
||||
}
|
||||
|
||||
// TestParseActionYMLMalformedYAML tests parsing with malformed YAML.
|
||||
func TestParseActionYMLMalformedYAML(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
content := testutil.TestActionNameLine +
|
||||
testutil.TestDescriptionLine +
|
||||
"invalid-yaml: [\n" + // Unclosed bracket
|
||||
" - item"
|
||||
|
||||
_, err := parseActionFromContent(t, content)
|
||||
if err == nil {
|
||||
t.Error("Expected error for malformed YAML, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
// TestParseActionYMLEmptyFile tests parsing an empty file.
|
||||
func TestParseActionYMLEmptyFile(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
actionPath := testutil.CreateTempActionFile(t, "")
|
||||
_, err := ParseActionYML(actionPath)
|
||||
// Empty file should return EOF error from YAML parser
|
||||
if err == nil {
|
||||
t.Error("Expected EOF error for empty file, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
// TestParsePermissionLineEdgeCases tests edge cases in permission line parsing.
|
||||
func TestParsePermissionLineEdgeCases(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
wantKey string
|
||||
wantValue string
|
||||
wantOK bool
|
||||
}{
|
||||
{
|
||||
name: "comment at start is parsed",
|
||||
input: "#contents: read",
|
||||
wantKey: "#contents",
|
||||
wantValue: "read",
|
||||
wantOK: true,
|
||||
},
|
||||
{
|
||||
name: "empty value after colon",
|
||||
input: "contents:",
|
||||
wantKey: "",
|
||||
wantValue: "",
|
||||
wantOK: false,
|
||||
},
|
||||
{
|
||||
name: "only spaces after colon",
|
||||
input: "contents: ",
|
||||
wantKey: "",
|
||||
wantValue: "",
|
||||
wantOK: false,
|
||||
},
|
||||
{
|
||||
name: "valid with inline comment",
|
||||
input: "contents: read # required",
|
||||
wantKey: "contents",
|
||||
wantValue: "read",
|
||||
wantOK: true,
|
||||
},
|
||||
{
|
||||
name: "valid with leading dash",
|
||||
input: "- issues: write",
|
||||
wantKey: "issues",
|
||||
wantValue: "write",
|
||||
wantOK: true,
|
||||
},
|
||||
{
|
||||
name: "no colon",
|
||||
input: "invalid permission line",
|
||||
wantKey: "",
|
||||
wantValue: "",
|
||||
wantOK: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
key, value, ok := parsePermissionLine(tt.input)
|
||||
|
||||
if ok != tt.wantOK {
|
||||
t.Errorf("parsePermissionLine() ok = %v, want %v", ok, tt.wantOK)
|
||||
}
|
||||
|
||||
if key != tt.wantKey {
|
||||
t.Errorf("parsePermissionLine() key = %q, want %q", key, tt.wantKey)
|
||||
}
|
||||
|
||||
if value != tt.wantValue {
|
||||
t.Errorf("parsePermissionLine() value = %q, want %q", value, tt.wantValue)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestProcessPermissionEntryIndentationEdgeCases tests indentation scenarios.
|
||||
func TestProcessPermissionEntryIndentationEdgeCases(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
line string
|
||||
content string
|
||||
initialIndent int
|
||||
wantBreak bool
|
||||
wantPermissionsLen int
|
||||
}{
|
||||
{
|
||||
name: "first item sets indent",
|
||||
line: testutil.TestContentsRead,
|
||||
content: "contents: read",
|
||||
initialIndent: -1,
|
||||
wantBreak: false,
|
||||
wantPermissionsLen: 1,
|
||||
},
|
||||
{
|
||||
name: "dedented breaks",
|
||||
line: "# contents: read",
|
||||
content: "contents: read",
|
||||
initialIndent: 2,
|
||||
wantBreak: true,
|
||||
wantPermissionsLen: 0,
|
||||
},
|
||||
{
|
||||
name: "same indent continues",
|
||||
line: "# issues: write",
|
||||
content: "issues: write",
|
||||
initialIndent: 3,
|
||||
wantBreak: false,
|
||||
wantPermissionsLen: 1,
|
||||
},
|
||||
{
|
||||
name: "invalid format skipped",
|
||||
line: "# invalid-line-no-colon",
|
||||
content: "invalid-line-no-colon",
|
||||
initialIndent: 3,
|
||||
wantBreak: false,
|
||||
wantPermissionsLen: 0,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
permissions := make(map[string]string)
|
||||
indent := tt.initialIndent
|
||||
|
||||
shouldBreak := processPermissionEntry(tt.line, tt.content, &indent, permissions)
|
||||
|
||||
if shouldBreak != tt.wantBreak {
|
||||
t.Errorf("processPermissionEntry() shouldBreak = %v, want %v", shouldBreak, tt.wantBreak)
|
||||
}
|
||||
|
||||
if len(permissions) != tt.wantPermissionsLen {
|
||||
t.Errorf(
|
||||
"processPermissionEntry() permissions length = %d, want %d",
|
||||
len(permissions),
|
||||
tt.wantPermissionsLen,
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestParsePermissionsFromCommentsEdgeCases tests edge cases in comment parsing.
|
||||
func TestParsePermissionsFromCommentsEdgeCases(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
content string
|
||||
wantPerms map[string]string
|
||||
wantErr bool
|
||||
description string
|
||||
}{
|
||||
{
|
||||
name: "duplicate permissions",
|
||||
content: testutil.TestPermissionsHeader +
|
||||
testutil.TestContentsRead +
|
||||
"# contents: write\n",
|
||||
wantPerms: map[string]string{"contents": "write"},
|
||||
wantErr: false,
|
||||
description: "last value wins",
|
||||
},
|
||||
{
|
||||
name: "mixed valid and invalid lines",
|
||||
content: testutil.TestPermissionsHeader +
|
||||
testutil.TestContentsRead +
|
||||
"# invalid-line-no-value\n" +
|
||||
"# issues: write\n",
|
||||
wantPerms: map[string]string{"contents": "read", "issues": "write"},
|
||||
wantErr: false,
|
||||
description: "invalid lines skipped",
|
||||
},
|
||||
{
|
||||
name: "permissions block ends at non-comment",
|
||||
content: testutil.TestPermissionsHeader +
|
||||
testutil.TestContentsRead +
|
||||
testutil.TestActionNameLine +
|
||||
"# issues: write\n",
|
||||
wantPerms: map[string]string{"contents": "read"},
|
||||
wantErr: false,
|
||||
description: "stops at first non-comment",
|
||||
},
|
||||
{
|
||||
name: "only permissions header",
|
||||
content: testutil.TestPermissionsHeader +
|
||||
testutil.TestActionNameLine,
|
||||
wantPerms: map[string]string{},
|
||||
wantErr: false,
|
||||
description: "empty permissions block",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
actionPath := testutil.CreateTempActionFile(t, tt.content)
|
||||
perms, err := parsePermissionsFromComments(actionPath)
|
||||
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("parsePermissionsFromComments() error = %v, wantErr %v", err, tt.wantErr)
|
||||
}
|
||||
|
||||
if !reflect.DeepEqual(perms, tt.wantPerms) {
|
||||
t.Errorf("parsePermissionsFromComments() = %v, want %v (%s)", perms, tt.wantPerms, tt.description)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestMergePermissionsEdgeCases tests permission merging edge cases.
|
||||
func TestMergePermissionsEdgeCases(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
yamlPerms map[string]string
|
||||
commentPerms map[string]string
|
||||
wantPerms map[string]string
|
||||
}{
|
||||
{
|
||||
name: "both nil",
|
||||
yamlPerms: nil,
|
||||
commentPerms: nil,
|
||||
wantPerms: nil,
|
||||
},
|
||||
{
|
||||
name: "yaml nil, comments empty",
|
||||
yamlPerms: nil,
|
||||
commentPerms: map[string]string{},
|
||||
wantPerms: nil,
|
||||
},
|
||||
{
|
||||
name: "yaml empty, comments nil",
|
||||
yamlPerms: map[string]string{},
|
||||
commentPerms: nil,
|
||||
wantPerms: map[string]string{},
|
||||
},
|
||||
{
|
||||
name: "yaml has value, comments override",
|
||||
yamlPerms: map[string]string{"contents": "read"},
|
||||
commentPerms: map[string]string{"issues": "write"},
|
||||
wantPerms: map[string]string{"contents": "read", "issues": "write"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
action := &ActionYML{Permissions: tt.yamlPerms}
|
||||
mergePermissions(action, tt.commentPerms)
|
||||
|
||||
if !reflect.DeepEqual(action.Permissions, tt.wantPerms) {
|
||||
t.Errorf("mergePermissions() = %v, want %v", action.Permissions, tt.wantPerms)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestDiscoverActionFilesWalkErrors tests error handling during directory walk.
|
||||
func TestDiscoverActionFilesWalkErrors(t *testing.T) {
|
||||
// Test with a path that doesn't exist
|
||||
_, err := DiscoverActionFiles("/nonexistent/path/that/does/not/exist", true, []string{})
|
||||
if err == nil {
|
||||
t.Error("Expected error for nonexistent directory, got nil")
|
||||
}
|
||||
|
||||
// Test that error message mentions the path
|
||||
if err != nil && !strings.Contains(err.Error(), "/nonexistent/path/that/does/not/exist") {
|
||||
t.Errorf("Expected error to mention path, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWalkFuncErrorHandling tests walkFunc error propagation.
|
||||
func TestWalkFuncErrorHandling(t *testing.T) {
|
||||
walker := &actionFileWalker{
|
||||
ignoredDirs: []string{},
|
||||
actionFiles: []string{},
|
||||
}
|
||||
|
||||
// Create a valid FileInfo for testing
|
||||
tmpDir := t.TempDir()
|
||||
info, err := os.Stat(tmpDir)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to stat temp dir: %v", err)
|
||||
}
|
||||
|
||||
// Test with valid directory - should return nil
|
||||
err = walker.walkFunc(tmpDir, info, nil)
|
||||
if err != nil {
|
||||
t.Errorf("walkFunc() with valid directory should return nil, got: %v", err)
|
||||
}
|
||||
|
||||
// Test with pre-existing error - should propagate
|
||||
testErr := filepath.SkipDir
|
||||
err = walker.walkFunc(tmpDir, info, testErr)
|
||||
if err != testErr {
|
||||
t.Errorf("walkFunc() should propagate error, "+testutil.TestMsgGotWant, err, testErr)
|
||||
}
|
||||
}
|
||||
|
||||
// TestParseActionYMLOnlyComments tests file with only comments.
|
||||
func TestParseActionYMLOnlyComments(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
content := "# This is a comment\n" +
|
||||
"# Another comment\n" +
|
||||
testutil.TestPermissionsHeader +
|
||||
testutil.TestContentsRead
|
||||
|
||||
_, err := parseActionFromContent(t, content)
|
||||
// File with only comments should return EOF error from YAML parser
|
||||
// (comments are parsed separately, but YAML decoder still needs valid YAML)
|
||||
if err == nil {
|
||||
t.Error("Expected EOF error for comment-only file, got nil")
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,15 @@
|
||||
package internal
|
||||
|
||||
import (
|
||||
"io"
|
||||
"testing"
|
||||
|
||||
"github.com/schollz/progressbar/v3"
|
||||
|
||||
"github.com/ivuorinen/gh-action-readme/testutil"
|
||||
)
|
||||
|
||||
func TestProgressBarManager_CreateProgressBar(t *testing.T) {
|
||||
func TestProgressBarManagerCreateProgressBar(t *testing.T) {
|
||||
t.Parallel()
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -18,28 +21,28 @@ func TestProgressBarManager_CreateProgressBar(t *testing.T) {
|
||||
{
|
||||
name: "normal progress bar",
|
||||
quiet: false,
|
||||
description: "Test progress",
|
||||
description: testutil.TestProgressDescription,
|
||||
total: 10,
|
||||
expectNil: false,
|
||||
},
|
||||
{
|
||||
name: "quiet mode returns nil",
|
||||
quiet: true,
|
||||
description: "Test progress",
|
||||
description: testutil.TestProgressDescription,
|
||||
total: 10,
|
||||
expectNil: true,
|
||||
},
|
||||
{
|
||||
name: "single item returns nil",
|
||||
quiet: false,
|
||||
description: "Test progress",
|
||||
description: testutil.TestProgressDescription,
|
||||
total: 1,
|
||||
expectNil: true,
|
||||
},
|
||||
{
|
||||
name: "zero items returns nil",
|
||||
quiet: false,
|
||||
description: "Test progress",
|
||||
description: testutil.TestProgressDescription,
|
||||
total: 0,
|
||||
expectNil: true,
|
||||
},
|
||||
@@ -64,7 +67,7 @@ func TestProgressBarManager_CreateProgressBar(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestProgressBarManager_CreateProgressBarForFiles(t *testing.T) {
|
||||
func TestProgressBarManagerCreateProgressBarForFiles(t *testing.T) {
|
||||
t.Parallel()
|
||||
pm := NewProgressBarManager(false)
|
||||
files := []string{"file1.yml", "file2.yml", "file3.yml"}
|
||||
@@ -76,33 +79,44 @@ func TestProgressBarManager_CreateProgressBarForFiles(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestProgressBarManager_FinishProgressBar(t *testing.T) {
|
||||
func TestProgressBarManagerNilSafeOperations(t *testing.T) {
|
||||
t.Parallel()
|
||||
// Use quiet mode to avoid cluttering test output
|
||||
pm := NewProgressBarManager(true)
|
||||
|
||||
// Test with nil bar (should not panic)
|
||||
pm.FinishProgressBar(nil)
|
||||
tests := []struct {
|
||||
name string
|
||||
operation func(*ProgressBarManager, *progressbar.ProgressBar)
|
||||
}{
|
||||
{
|
||||
name: "FinishProgressBar handles nil",
|
||||
operation: func(pm *ProgressBarManager, bar *progressbar.ProgressBar) {
|
||||
pm.FinishProgressBar(bar)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "UpdateProgressBar handles nil",
|
||||
operation: func(pm *ProgressBarManager, bar *progressbar.ProgressBar) {
|
||||
pm.UpdateProgressBar(bar)
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// Test with actual bar (will be nil in quiet mode)
|
||||
bar := pm.CreateProgressBar("Test", 5)
|
||||
pm.FinishProgressBar(bar) // Should handle nil gracefully
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
// Use quiet mode to avoid cluttering test output
|
||||
pm := NewProgressBarManager(true)
|
||||
|
||||
// Should not panic with nil
|
||||
tt.operation(pm, nil)
|
||||
|
||||
// Should not panic with actual bar (will be nil in quiet mode)
|
||||
bar := pm.CreateProgressBar("Test", 5)
|
||||
tt.operation(pm, bar)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestProgressBarManager_UpdateProgressBar(t *testing.T) {
|
||||
t.Parallel()
|
||||
// Use quiet mode to avoid cluttering test output
|
||||
pm := NewProgressBarManager(true)
|
||||
|
||||
// Test with nil bar (should not panic)
|
||||
pm.UpdateProgressBar(nil)
|
||||
|
||||
// Test with actual bar (will be nil in quiet mode)
|
||||
bar := pm.CreateProgressBar("Test", 5)
|
||||
pm.UpdateProgressBar(bar) // Should handle nil gracefully
|
||||
}
|
||||
|
||||
func TestProgressBarManager_ProcessWithProgressBar(t *testing.T) {
|
||||
func TestProgressBarManagerProcessWithProgressBar(t *testing.T) {
|
||||
t.Parallel()
|
||||
// Use NullProgressManager to avoid cluttering test output
|
||||
pm := NewNullProgressManager()
|
||||
@@ -126,7 +140,7 @@ func TestProgressBarManager_ProcessWithProgressBar(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestProgressBarManager_ProcessWithProgressBar_QuietMode(t *testing.T) {
|
||||
func TestProgressBarManagerProcessWithProgressBarQuietMode(t *testing.T) {
|
||||
t.Parallel()
|
||||
pm := NewProgressBarManager(true) // quiet mode
|
||||
items := []string{"item1", "item2"}
|
||||
@@ -146,3 +160,32 @@ func TestProgressBarManager_ProcessWithProgressBar_QuietMode(t *testing.T) {
|
||||
t.Errorf("expected %d processed items, got %d", len(items), len(processedItems))
|
||||
}
|
||||
}
|
||||
|
||||
// TestProgressBarManagerFinishProgressBarWithNewline tests finishing with newline.
|
||||
func TestProgressBarManagerFinishProgressBarWithNewline(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
bar *progressbar.ProgressBar
|
||||
}{
|
||||
{
|
||||
name: "with valid progress bar",
|
||||
bar: progressbar.NewOptions(10, progressbar.OptionSetWriter(io.Discard)),
|
||||
},
|
||||
{
|
||||
name: "with nil progress bar",
|
||||
bar: nil,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
pm := NewProgressBarManager(false)
|
||||
// Should not panic
|
||||
pm.FinishProgressBarWithNewline(tt.bar)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package internal
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"text/template"
|
||||
|
||||
@@ -12,7 +13,7 @@ import (
|
||||
"github.com/ivuorinen/gh-action-readme/internal/dependencies"
|
||||
"github.com/ivuorinen/gh-action-readme/internal/git"
|
||||
"github.com/ivuorinen/gh-action-readme/internal/validation"
|
||||
"github.com/ivuorinen/gh-action-readme/templates_embed"
|
||||
templatesembed "github.com/ivuorinen/gh-action-readme/templates_embed"
|
||||
)
|
||||
|
||||
// TemplateOptions defines options for rendering templates.
|
||||
@@ -37,6 +38,10 @@ type TemplateData struct {
|
||||
// Computed Values
|
||||
UsesStatement string `json:"uses_statement"`
|
||||
|
||||
// Path information for subdirectory extraction
|
||||
ActionPath string `json:"action_path,omitempty"`
|
||||
RepoRoot string `json:"repo_root,omitempty"`
|
||||
|
||||
// Dependencies (populated by dependency analysis)
|
||||
Dependencies []dependencies.Dependency `json:"dependencies,omitempty"`
|
||||
}
|
||||
@@ -55,32 +60,34 @@ func templateFuncs() template.FuncMap {
|
||||
}
|
||||
}
|
||||
|
||||
// getGitOrg returns the Git organization from template data.
|
||||
func getGitOrg(data any) string {
|
||||
// getFieldWithFallback extracts a field from TemplateData with Git-then-Config fallback logic.
|
||||
func getFieldWithFallback(data any, gitGetter, configGetter func(*TemplateData) string, defaultValue string) string {
|
||||
if td, ok := data.(*TemplateData); ok {
|
||||
if td.Git.Organization != "" {
|
||||
return td.Git.Organization
|
||||
if gitValue := gitGetter(td); gitValue != "" {
|
||||
return gitValue
|
||||
}
|
||||
if td.Config.Organization != "" {
|
||||
return td.Config.Organization
|
||||
if configValue := configGetter(td); configValue != "" {
|
||||
return configValue
|
||||
}
|
||||
}
|
||||
|
||||
return appconstants.DefaultOrgPlaceholder
|
||||
return defaultValue
|
||||
}
|
||||
|
||||
// getGitOrg returns the Git organization from template data.
|
||||
func getGitOrg(data any) string {
|
||||
return getFieldWithFallback(data,
|
||||
func(td *TemplateData) string { return td.Git.Organization },
|
||||
func(td *TemplateData) string { return td.Config.Organization },
|
||||
appconstants.DefaultOrgPlaceholder)
|
||||
}
|
||||
|
||||
// getGitRepo returns the Git repository name from template data.
|
||||
func getGitRepo(data any) string {
|
||||
if td, ok := data.(*TemplateData); ok {
|
||||
if td.Git.Repository != "" {
|
||||
return td.Git.Repository
|
||||
}
|
||||
if td.Config.Repository != "" {
|
||||
return td.Config.Repository
|
||||
}
|
||||
}
|
||||
|
||||
return appconstants.DefaultRepoPlaceholder
|
||||
return getFieldWithFallback(data,
|
||||
func(td *TemplateData) string { return td.Git.Repository },
|
||||
func(td *TemplateData) string { return td.Config.Repository },
|
||||
appconstants.DefaultRepoPlaceholder)
|
||||
}
|
||||
|
||||
// getGitUsesString returns a complete uses string for the action.
|
||||
@@ -122,41 +129,98 @@ func formatVersion(version string) string {
|
||||
return version
|
||||
}
|
||||
|
||||
// buildUsesString constructs the uses string with optional action name.
|
||||
// buildUsesString constructs the uses string with optional subdirectory path.
|
||||
func buildUsesString(td *TemplateData, org, repo, version string) string {
|
||||
// Use the validation package's FormatUsesStatement for consistency
|
||||
if org == "" || repo == "" {
|
||||
return appconstants.DefaultUsesPlaceholder
|
||||
}
|
||||
|
||||
// For actions within subdirectories, include the action name
|
||||
if td.Name != "" && repo != "" {
|
||||
actionName := validation.SanitizeActionName(td.Name)
|
||||
if actionName != "" && actionName != repo {
|
||||
// Check if this looks like a subdirectory action
|
||||
return validation.FormatUsesStatement(org, repo+"/"+actionName, version)
|
||||
}
|
||||
// For monorepo actions in subdirectories, extract the actual directory path
|
||||
subdir := extractActionSubdirectory(td.ActionPath, td.RepoRoot)
|
||||
|
||||
if subdir != "" {
|
||||
// Action is in a subdirectory: org/repo/subdir@version
|
||||
return validation.FormatUsesStatement(org, repo+"/"+subdir, version)
|
||||
}
|
||||
|
||||
// Action is at repo root: org/repo@version
|
||||
return validation.FormatUsesStatement(org, repo, version)
|
||||
}
|
||||
|
||||
// getActionVersion returns the action version from template data.
|
||||
func getActionVersion(data any) string {
|
||||
if td, ok := data.(*TemplateData); ok {
|
||||
if td.Config.Version != "" {
|
||||
return td.Config.Version
|
||||
}
|
||||
// extractActionSubdirectory extracts the subdirectory path for an action relative to repo root.
|
||||
// For monorepo actions (e.g., org/repo/subdir/action.yml), returns "subdir".
|
||||
// For repo-root actions (e.g., org/repo/action.yml), returns empty string.
|
||||
// Returns empty string if paths cannot be determined.
|
||||
func extractActionSubdirectory(actionPath, repoRoot string) string {
|
||||
// Validate inputs
|
||||
if actionPath == "" || repoRoot == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
// Get absolute paths for reliable comparison
|
||||
absActionPath, err := filepath.Abs(actionPath)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
absRepoRoot, err := filepath.Abs(repoRoot)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
// Get the directory containing action.yml
|
||||
actionDir := filepath.Dir(absActionPath)
|
||||
|
||||
// Calculate relative path from repo root to action directory
|
||||
relPath, err := filepath.Rel(absRepoRoot, actionDir)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
// If relative path is "." or empty, action is at repo root
|
||||
if relPath == "." || relPath == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
// If relative path starts with "..", action is outside repo (shouldn't happen)
|
||||
if strings.HasPrefix(relPath, "..") {
|
||||
return ""
|
||||
}
|
||||
|
||||
// Return the subdirectory path (e.g., "actions/csharp-build")
|
||||
return relPath
|
||||
}
|
||||
|
||||
// getActionVersion returns the action version from template data.
|
||||
// Priority: 1) Config.Version (explicit override), 2) Default branch (if enabled), 3) "v1" (fallback).
|
||||
func getActionVersion(data any) string {
|
||||
td, ok := data.(*TemplateData)
|
||||
if !ok {
|
||||
return "v1"
|
||||
}
|
||||
|
||||
// Priority 1: Explicit version override
|
||||
if td.Config.Version != "" {
|
||||
return td.Config.Version
|
||||
}
|
||||
|
||||
// Priority 2: Use default branch if enabled and available
|
||||
if td.Config.UseDefaultBranch && td.Git.DefaultBranch != "" {
|
||||
return td.Git.DefaultBranch
|
||||
}
|
||||
|
||||
// Priority 3: Fallback
|
||||
return "v1"
|
||||
}
|
||||
|
||||
// BuildTemplateData constructs comprehensive template data from action and configuration.
|
||||
func BuildTemplateData(action *ActionYML, config *AppConfig, repoRoot, actionPath string) *TemplateData {
|
||||
data := &TemplateData{
|
||||
ActionYML: action,
|
||||
Config: config,
|
||||
ActionYML: action,
|
||||
Config: config,
|
||||
ActionPath: actionPath,
|
||||
RepoRoot: repoRoot,
|
||||
}
|
||||
|
||||
// Populate Git information
|
||||
@@ -227,7 +291,7 @@ func analyzeDependencies(actionPath string, config *AppConfig, gitInfo git.RepoI
|
||||
|
||||
// RenderReadme renders a README using a Go template and the parsed action.yml data.
|
||||
func RenderReadme(action any, opts TemplateOptions) (string, error) {
|
||||
tmplContent, err := templates_embed.ReadTemplate(opts.TemplatePath)
|
||||
tmplContent, err := templatesembed.ReadTemplate(opts.TemplatePath)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
@@ -239,11 +303,11 @@ func RenderReadme(action any, opts TemplateOptions) (string, error) {
|
||||
}
|
||||
var head, foot string
|
||||
if opts.HeaderPath != "" {
|
||||
h, _ := templates_embed.ReadTemplate(opts.HeaderPath)
|
||||
h, _ := templatesembed.ReadTemplate(opts.HeaderPath)
|
||||
head = string(h)
|
||||
}
|
||||
if opts.FooterPath != "" {
|
||||
f, _ := templates_embed.ReadTemplate(opts.FooterPath)
|
||||
f, _ := templatesembed.ReadTemplate(opts.FooterPath)
|
||||
foot = string(f)
|
||||
}
|
||||
// Wrap template output in header/footer
|
||||
|
||||
165
internal/template_helper_test.go
Normal file
165
internal/template_helper_test.go
Normal file
@@ -0,0 +1,165 @@
|
||||
package internal
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/ivuorinen/gh-action-readme/internal/dependencies"
|
||||
"github.com/ivuorinen/gh-action-readme/internal/git"
|
||||
"github.com/ivuorinen/gh-action-readme/testutil"
|
||||
)
|
||||
|
||||
// TestAssertTemplateData_Helper tests the assertTemplateData helper function.
|
||||
func TestAssertTemplateDataHelper(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
setup func() (*TemplateData, *ActionYML, *AppConfig)
|
||||
wantOrg string
|
||||
wantRepo string
|
||||
}{
|
||||
{
|
||||
name: "valid template data",
|
||||
setup: func() (*TemplateData, *ActionYML, *AppConfig) {
|
||||
action := &ActionYML{
|
||||
Name: "Test Action",
|
||||
Description: "A test action",
|
||||
}
|
||||
config := &AppConfig{
|
||||
Organization: testutil.TestOrgName,
|
||||
Repository: testutil.TestRepoName,
|
||||
}
|
||||
data := &TemplateData{
|
||||
ActionYML: action,
|
||||
Git: git.RepoInfo{
|
||||
Organization: testutil.TestOrgName,
|
||||
Repository: testutil.TestRepoName,
|
||||
},
|
||||
Config: config,
|
||||
}
|
||||
|
||||
return data, action, config
|
||||
},
|
||||
wantOrg: testutil.TestOrgName,
|
||||
wantRepo: testutil.TestRepoName,
|
||||
},
|
||||
{
|
||||
name: "template data with dependencies",
|
||||
setup: func() (*TemplateData, *ActionYML, *AppConfig) {
|
||||
action := &ActionYML{
|
||||
Name: "Action with deps",
|
||||
}
|
||||
config := &AppConfig{
|
||||
Organization: testutil.MyOrgName,
|
||||
Repository: testutil.MyRepoName,
|
||||
AnalyzeDependencies: true,
|
||||
}
|
||||
data := &TemplateData{
|
||||
ActionYML: action,
|
||||
Git: git.RepoInfo{
|
||||
Organization: testutil.MyOrgName,
|
||||
Repository: testutil.MyRepoName,
|
||||
},
|
||||
Config: config,
|
||||
Dependencies: []dependencies.Dependency{}, // Empty slice, not nil
|
||||
}
|
||||
|
||||
return data, action, config
|
||||
},
|
||||
wantOrg: testutil.MyOrgName,
|
||||
wantRepo: testutil.MyRepoName,
|
||||
},
|
||||
{
|
||||
name: "template data with empty organization",
|
||||
setup: func() (*TemplateData, *ActionYML, *AppConfig) {
|
||||
action := &ActionYML{
|
||||
Name: "Test",
|
||||
}
|
||||
config := &AppConfig{
|
||||
Organization: "",
|
||||
Repository: testutil.RepoName,
|
||||
}
|
||||
data := &TemplateData{
|
||||
ActionYML: action,
|
||||
Git: git.RepoInfo{
|
||||
Organization: "",
|
||||
Repository: testutil.RepoName,
|
||||
},
|
||||
Config: config,
|
||||
}
|
||||
|
||||
return data, action, config
|
||||
},
|
||||
wantOrg: "",
|
||||
wantRepo: testutil.RepoName,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
data, action, config := tt.setup()
|
||||
|
||||
// Call the helper - it validates the template data
|
||||
assertTemplateData(t, data, action, config, tt.wantOrg, tt.wantRepo)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestPrepareTestActionFile_Helper tests the prepareTestActionFile helper function.
|
||||
func TestPrepareTestActionFileHelper(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
actionPath string
|
||||
wantExists bool
|
||||
}{
|
||||
{
|
||||
name: "analyzer fixture composite action",
|
||||
actionPath: testutil.AnalyzerFixturePath + "composite-action.yml",
|
||||
wantExists: true,
|
||||
},
|
||||
{
|
||||
name: "analyzer fixture docker action",
|
||||
actionPath: testutil.AnalyzerFixturePath + "docker-action.yml",
|
||||
wantExists: true,
|
||||
},
|
||||
{
|
||||
name: "analyzer fixture javascript action",
|
||||
actionPath: testutil.AnalyzerFixturePath + "javascript-action.yml",
|
||||
wantExists: true,
|
||||
},
|
||||
{
|
||||
name: "nonexistent file path",
|
||||
actionPath: testutil.AnalyzerFixturePath + "nonexistent.yml",
|
||||
wantExists: true, // Helper creates a path, even if file doesn't exist
|
||||
},
|
||||
{
|
||||
name: "non-analyzer path",
|
||||
actionPath: "some/other/path.yml",
|
||||
wantExists: true, // Returns tmpDir path
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Call the helper - it prepares a test action file
|
||||
result := prepareTestActionFile(t, tt.actionPath)
|
||||
|
||||
// Verify we got a path
|
||||
if result == "" {
|
||||
t.Error("prepareTestActionFile returned empty path")
|
||||
}
|
||||
|
||||
// Verify it's an absolute path or relative path
|
||||
if !filepath.IsAbs(result) && !filepath.IsLocal(result) {
|
||||
t.Logf("Note: path may be relative or absolute: %s", result)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
597
internal/template_test.go
Normal file
597
internal/template_test.go
Normal file
@@ -0,0 +1,597 @@
|
||||
package internal
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/ivuorinen/gh-action-readme/appconstants"
|
||||
"github.com/ivuorinen/gh-action-readme/internal/git"
|
||||
"github.com/ivuorinen/gh-action-readme/testutil"
|
||||
)
|
||||
|
||||
// templateDataParams holds parameters for creating test TemplateData.
|
||||
type templateDataParams struct {
|
||||
actionName string
|
||||
version string
|
||||
useDefaultBranch bool
|
||||
defaultBranch string
|
||||
org string
|
||||
repo string
|
||||
actionPath string
|
||||
repoRoot string
|
||||
}
|
||||
|
||||
// newTemplateData creates a TemplateData with the provided templateDataParams.
|
||||
// Zero values are preserved as-is; this helper does not apply defaults.
|
||||
// Callers must set defaults themselves or use a separate defaulting helper.
|
||||
func newTemplateData(params templateDataParams) *TemplateData {
|
||||
var actionYML *ActionYML
|
||||
if params.actionName != "" {
|
||||
actionYML = &ActionYML{Name: params.actionName}
|
||||
}
|
||||
|
||||
return &TemplateData{
|
||||
ActionYML: actionYML,
|
||||
Config: &AppConfig{
|
||||
Version: params.version,
|
||||
UseDefaultBranch: params.useDefaultBranch,
|
||||
},
|
||||
Git: git.RepoInfo{
|
||||
Organization: params.org,
|
||||
Repository: params.repo,
|
||||
DefaultBranch: params.defaultBranch,
|
||||
},
|
||||
ActionPath: params.actionPath,
|
||||
RepoRoot: params.repoRoot,
|
||||
}
|
||||
}
|
||||
|
||||
// TestExtractActionSubdirectory tests the extractActionSubdirectory function.
|
||||
func TestExtractActionSubdirectory(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
actionPath string
|
||||
repoRoot string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: testutil.TestCaseNameSubdirAction,
|
||||
actionPath: "/repo/actions/csharp-build/action.yml",
|
||||
repoRoot: "/repo",
|
||||
want: "actions/csharp-build",
|
||||
},
|
||||
{
|
||||
name: "single level subdirectory",
|
||||
actionPath: testutil.TestRepoBuildActionPath,
|
||||
repoRoot: "/repo",
|
||||
want: "build",
|
||||
},
|
||||
{
|
||||
name: "deeply nested subdirectory",
|
||||
actionPath: "/repo/a/b/c/d/action.yml",
|
||||
repoRoot: "/repo",
|
||||
want: "a/b/c/d",
|
||||
},
|
||||
{
|
||||
name: testutil.TestCaseNameRootAction,
|
||||
actionPath: testutil.TestRepoActionPath,
|
||||
repoRoot: "/repo",
|
||||
want: "",
|
||||
},
|
||||
{
|
||||
name: "empty action path",
|
||||
actionPath: "",
|
||||
repoRoot: "/repo",
|
||||
want: "",
|
||||
},
|
||||
{
|
||||
name: "empty repo root",
|
||||
actionPath: testutil.TestRepoActionPath,
|
||||
repoRoot: "",
|
||||
want: "",
|
||||
},
|
||||
{
|
||||
name: "both empty",
|
||||
actionPath: "",
|
||||
repoRoot: "",
|
||||
want: "",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
got := extractActionSubdirectory(tt.actionPath, tt.repoRoot)
|
||||
|
||||
// Normalize paths for cross-platform compatibility
|
||||
want := filepath.ToSlash(tt.want)
|
||||
got = filepath.ToSlash(got)
|
||||
|
||||
if got != want {
|
||||
t.Errorf("extractActionSubdirectory() = %q, want %q", got, want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildUsesString tests the buildUsesString function with subdirectory extraction.
|
||||
func TestBuildUsesString(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
td *TemplateData
|
||||
org string
|
||||
repo string
|
||||
version string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "monorepo with subdirectory",
|
||||
td: &TemplateData{
|
||||
ActionPath: "/repo/actions/csharp-build/action.yml",
|
||||
RepoRoot: "/repo",
|
||||
},
|
||||
org: "ivuorinen",
|
||||
repo: "actions",
|
||||
version: "@main",
|
||||
want: "ivuorinen/actions/actions/csharp-build@main",
|
||||
},
|
||||
{
|
||||
name: testutil.TestCaseNameRootAction,
|
||||
td: &TemplateData{
|
||||
ActionPath: testutil.TestRepoActionPath,
|
||||
RepoRoot: "/repo",
|
||||
},
|
||||
org: "ivuorinen",
|
||||
repo: "my-action",
|
||||
version: "@main",
|
||||
want: "ivuorinen/my-action@main",
|
||||
},
|
||||
{
|
||||
name: "empty org",
|
||||
td: &TemplateData{
|
||||
ActionPath: testutil.TestRepoBuildActionPath,
|
||||
RepoRoot: "/repo",
|
||||
},
|
||||
org: "",
|
||||
repo: "actions",
|
||||
version: "@main",
|
||||
want: "your-org/your-action@v1",
|
||||
},
|
||||
{
|
||||
name: "empty repo",
|
||||
td: &TemplateData{
|
||||
ActionPath: testutil.TestRepoBuildActionPath,
|
||||
RepoRoot: "/repo",
|
||||
},
|
||||
org: "ivuorinen",
|
||||
repo: "",
|
||||
version: "@main",
|
||||
want: "your-org/your-action@v1",
|
||||
},
|
||||
{
|
||||
name: "missing paths in template data",
|
||||
td: &TemplateData{
|
||||
ActionPath: "",
|
||||
RepoRoot: "",
|
||||
},
|
||||
org: "ivuorinen",
|
||||
repo: "actions",
|
||||
version: "@v1",
|
||||
want: "ivuorinen/actions@v1",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
got := buildUsesString(tt.td, tt.org, tt.repo, tt.version)
|
||||
|
||||
// Normalize paths for cross-platform compatibility
|
||||
want := filepath.ToSlash(tt.want)
|
||||
got = filepath.ToSlash(got)
|
||||
|
||||
if got != want {
|
||||
t.Errorf("buildUsesString() = %q, want %q", got, want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetActionVersion tests the getActionVersion function with priority logic.
|
||||
func TestGetActionVersion(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
data any
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "config version override",
|
||||
data: newTemplateData(templateDataParams{version: "v2.0.0", useDefaultBranch: true, defaultBranch: "main"}),
|
||||
want: "v2.0.0",
|
||||
},
|
||||
{
|
||||
name: "use default branch when enabled",
|
||||
data: newTemplateData(templateDataParams{useDefaultBranch: true, defaultBranch: "main"}),
|
||||
want: "main",
|
||||
},
|
||||
{
|
||||
name: "use default branch master",
|
||||
data: newTemplateData(templateDataParams{useDefaultBranch: true, defaultBranch: "master"}),
|
||||
want: "master",
|
||||
},
|
||||
{
|
||||
name: "fallback to v1 when default branch disabled",
|
||||
data: newTemplateData(templateDataParams{useDefaultBranch: false, defaultBranch: "main"}),
|
||||
want: "v1",
|
||||
},
|
||||
{
|
||||
name: "fallback to v1 when default branch not detected",
|
||||
data: newTemplateData(templateDataParams{useDefaultBranch: true}),
|
||||
want: "v1",
|
||||
},
|
||||
{
|
||||
name: "fallback to v1 when data is invalid",
|
||||
data: "invalid",
|
||||
want: "v1",
|
||||
},
|
||||
{
|
||||
name: "fallback to v1 when data is nil",
|
||||
data: nil,
|
||||
want: "v1",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
got := getActionVersion(tt.data)
|
||||
if got != tt.want {
|
||||
t.Errorf("getActionVersion() = %q, want %q", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetGitUsesString tests the complete integration of gitUsesString template function.
|
||||
func TestGetGitUsesString(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
data *TemplateData
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "monorepo action with default branch",
|
||||
data: newTemplateData(templateDataParams{
|
||||
actionName: "C# Build",
|
||||
useDefaultBranch: true,
|
||||
defaultBranch: "main",
|
||||
org: "ivuorinen",
|
||||
repo: "actions",
|
||||
actionPath: "/repo/csharp-build/action.yml",
|
||||
repoRoot: "/repo",
|
||||
}),
|
||||
want: "ivuorinen/actions/csharp-build@main",
|
||||
},
|
||||
{
|
||||
name: "monorepo action with explicit version",
|
||||
data: newTemplateData(templateDataParams{
|
||||
actionName: "Build Action",
|
||||
version: "v1.0.0",
|
||||
useDefaultBranch: true,
|
||||
defaultBranch: "main",
|
||||
org: "org",
|
||||
repo: "actions",
|
||||
actionPath: testutil.TestRepoBuildActionPath,
|
||||
repoRoot: "/repo",
|
||||
}),
|
||||
want: "org/actions/build@v1.0.0",
|
||||
},
|
||||
{
|
||||
name: "root level action with default branch",
|
||||
data: newTemplateData(templateDataParams{
|
||||
actionName: testutil.TestMyAction,
|
||||
useDefaultBranch: true,
|
||||
defaultBranch: "develop",
|
||||
org: "user",
|
||||
repo: "my-action",
|
||||
actionPath: testutil.TestRepoActionPath,
|
||||
repoRoot: "/repo",
|
||||
}),
|
||||
want: "user/my-action@develop",
|
||||
},
|
||||
{
|
||||
name: "action with use_default_branch disabled",
|
||||
data: newTemplateData(templateDataParams{
|
||||
actionName: testutil.TestActionName,
|
||||
useDefaultBranch: false,
|
||||
defaultBranch: "main",
|
||||
org: "org",
|
||||
repo: "test",
|
||||
actionPath: testutil.TestRepoActionPath,
|
||||
repoRoot: "/repo",
|
||||
}),
|
||||
want: "org/test@v1",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
got := getGitUsesString(tt.data)
|
||||
|
||||
// Normalize paths for cross-platform compatibility
|
||||
want := filepath.ToSlash(tt.want)
|
||||
got = filepath.ToSlash(got)
|
||||
|
||||
if got != want {
|
||||
t.Errorf("getGitUsesString() = %q, want %q", got, want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestFormatVersion tests the formatVersion function.
|
||||
func TestFormatVersion(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
version string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "empty version",
|
||||
version: "",
|
||||
want: "@v1",
|
||||
},
|
||||
{
|
||||
name: "whitespace only version",
|
||||
version: " ",
|
||||
want: "@v1",
|
||||
},
|
||||
{
|
||||
name: "version without @",
|
||||
version: "v1.2.3",
|
||||
want: testutil.TestVersionWithAt,
|
||||
},
|
||||
{
|
||||
name: "version with @",
|
||||
version: testutil.TestVersionWithAt,
|
||||
want: testutil.TestVersionWithAt,
|
||||
},
|
||||
{
|
||||
name: "main branch",
|
||||
version: "main",
|
||||
want: "@main",
|
||||
},
|
||||
{
|
||||
name: "version with @ and spaces",
|
||||
version: " @v2.0.0 ",
|
||||
want: "@v2.0.0",
|
||||
},
|
||||
{
|
||||
name: "sha version",
|
||||
version: "abc123",
|
||||
want: "@abc123",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
got := formatVersion(tt.version)
|
||||
if got != tt.want {
|
||||
t.Errorf("formatVersion(%q) = %q, want %q", tt.version, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildTemplateData tests the BuildTemplateData function.
|
||||
func TestBuildTemplateData(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
action *ActionYML
|
||||
config *AppConfig
|
||||
repoRoot string
|
||||
actionPath string
|
||||
wantOrg string
|
||||
wantRepo string
|
||||
}{
|
||||
{
|
||||
name: "basic action with config overrides",
|
||||
action: &ActionYML{
|
||||
Name: testutil.TestActionName,
|
||||
Description: "Test description",
|
||||
},
|
||||
config: &AppConfig{
|
||||
Organization: "testorg",
|
||||
Repository: "testrepo",
|
||||
},
|
||||
repoRoot: ".",
|
||||
actionPath: appconstants.ActionFileNameYML,
|
||||
wantOrg: "testorg",
|
||||
wantRepo: "testrepo",
|
||||
},
|
||||
{
|
||||
name: "action without config overrides",
|
||||
action: &ActionYML{
|
||||
Name: "Another Action",
|
||||
Description: "Another description",
|
||||
},
|
||||
config: &AppConfig{},
|
||||
repoRoot: ".",
|
||||
actionPath: appconstants.ActionFileNameYML,
|
||||
wantOrg: "",
|
||||
wantRepo: "",
|
||||
},
|
||||
{
|
||||
name: "action with dependency analysis enabled",
|
||||
action: &ActionYML{
|
||||
Name: "Dep Action",
|
||||
Description: "Action with deps",
|
||||
},
|
||||
config: &AppConfig{
|
||||
Organization: "deporg",
|
||||
Repository: "deprepo",
|
||||
AnalyzeDependencies: true,
|
||||
},
|
||||
repoRoot: ".",
|
||||
actionPath: "../testdata/composite-action/action.yml",
|
||||
wantOrg: "deporg",
|
||||
wantRepo: "deprepo",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
data := BuildTemplateData(tt.action, tt.config, tt.repoRoot, tt.actionPath)
|
||||
assertTemplateData(t, data, tt.action, tt.config, tt.wantOrg, tt.wantRepo)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func assertTemplateData(
|
||||
t *testing.T,
|
||||
data *TemplateData,
|
||||
action *ActionYML,
|
||||
config *AppConfig,
|
||||
wantOrg, wantRepo string,
|
||||
) {
|
||||
t.Helper()
|
||||
|
||||
if data == nil {
|
||||
t.Fatal("BuildTemplateData() returned nil")
|
||||
}
|
||||
|
||||
if data.ActionYML != action {
|
||||
t.Error("BuildTemplateData() did not preserve ActionYML")
|
||||
}
|
||||
|
||||
if data.Config != config {
|
||||
t.Error("BuildTemplateData() did not preserve Config")
|
||||
}
|
||||
|
||||
if config.Organization != "" && data.Git.Organization != wantOrg {
|
||||
t.Errorf("BuildTemplateData() Git.Organization = %q, want %q", data.Git.Organization, wantOrg)
|
||||
}
|
||||
|
||||
if config.Repository != "" && data.Git.Repository != wantRepo {
|
||||
t.Errorf("BuildTemplateData() Git.Repository = %q, want %q", data.Git.Repository, wantRepo)
|
||||
}
|
||||
|
||||
if config.AnalyzeDependencies && data.Dependencies == nil {
|
||||
t.Error("BuildTemplateData() expected Dependencies to be set when AnalyzeDependencies is true")
|
||||
}
|
||||
}
|
||||
|
||||
// TestAnalyzeDependencies tests the analyzeDependencies function.
|
||||
// prepareTestActionFile prepares a test action file for analyzeDependencies tests.
|
||||
func prepareTestActionFile(t *testing.T, actionPath string) string {
|
||||
t.Helper()
|
||||
|
||||
if strings.HasPrefix(actionPath, "../../testdata/analyzer/") &&
|
||||
actionPath != "../../testdata/analyzer/nonexistent.yml" {
|
||||
filename := filepath.Base(actionPath)
|
||||
yamlContent := testutil.MustReadAnalyzerFixture(filename)
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
tmpPath := filepath.Join(tmpDir, appconstants.ActionFileNameYML)
|
||||
tmpPath = testutil.ValidateTestPath(t, tmpPath, tmpDir)
|
||||
testutil.WriteTestFile(t, tmpPath, yamlContent)
|
||||
|
||||
return tmpPath
|
||||
}
|
||||
|
||||
// For nonexistent file test
|
||||
return filepath.Join(t.TempDir(), "nonexistent.yml")
|
||||
}
|
||||
|
||||
func TestAnalyzeDependencies(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
actionPath string
|
||||
config *AppConfig
|
||||
expectNil bool
|
||||
}{
|
||||
{
|
||||
name: "valid composite action without GitHub token",
|
||||
actionPath: "../../testdata/analyzer/composite-action.yml",
|
||||
config: &AppConfig{},
|
||||
expectNil: false,
|
||||
},
|
||||
{
|
||||
name: "nonexistent action file",
|
||||
actionPath: "../../testdata/analyzer/nonexistent.yml",
|
||||
config: &AppConfig{},
|
||||
expectNil: false, // Should return empty slice, not nil
|
||||
},
|
||||
{
|
||||
name: "docker action without token",
|
||||
actionPath: "../../testdata/analyzer/docker-action.yml",
|
||||
config: &AppConfig{},
|
||||
expectNil: false,
|
||||
},
|
||||
{
|
||||
name: "javascript action without token",
|
||||
actionPath: "../../testdata/analyzer/javascript-action.yml",
|
||||
config: &AppConfig{},
|
||||
expectNil: false,
|
||||
},
|
||||
{
|
||||
name: "invalid yaml file",
|
||||
actionPath: "../../testdata/analyzer/invalid.yml",
|
||||
config: &AppConfig{},
|
||||
expectNil: false, // Should gracefully handle errors and return empty slice
|
||||
},
|
||||
{
|
||||
name: testutil.TestCaseNamePathTraversalAttempt,
|
||||
actionPath: "../../etc/passwd",
|
||||
config: &AppConfig{},
|
||||
expectNil: false, // Returns empty slice for invalid paths
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
actionPath := prepareTestActionFile(t, tt.actionPath)
|
||||
|
||||
gitInfo := git.RepoInfo{
|
||||
Organization: "testorg",
|
||||
Repository: "testrepo",
|
||||
}
|
||||
|
||||
result := analyzeDependencies(actionPath, tt.config, gitInfo)
|
||||
|
||||
if tt.expectNil && result != nil {
|
||||
t.Errorf("analyzeDependencies() expected nil, got %v", result)
|
||||
}
|
||||
|
||||
if !tt.expectNil && result == nil {
|
||||
t.Error("analyzeDependencies() returned nil, expected non-nil slice")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -19,7 +19,7 @@ var (
|
||||
_ ErrorReporter = (*NullOutput)(nil)
|
||||
_ ErrorFormatter = (*NullOutput)(nil)
|
||||
_ ProgressReporter = (*NullOutput)(nil)
|
||||
_ OutputConfig = (*NullOutput)(nil)
|
||||
_ QuietChecker = (*NullOutput)(nil)
|
||||
_ CompleteOutput = (*NullOutput)(nil)
|
||||
)
|
||||
|
||||
@@ -34,28 +34,44 @@ func (no *NullOutput) IsQuiet() bool {
|
||||
}
|
||||
|
||||
// Success is a no-op.
|
||||
func (no *NullOutput) Success(_ string, _ ...any) {}
|
||||
func (no *NullOutput) Success(_ string, _ ...any) {
|
||||
// Intentionally empty: NullOutput suppresses all output for testing.
|
||||
}
|
||||
|
||||
// Error is a no-op.
|
||||
func (no *NullOutput) Error(_ string, _ ...any) {}
|
||||
func (no *NullOutput) Error(_ string, _ ...any) {
|
||||
// Intentionally empty: NullOutput suppresses all output for testing.
|
||||
}
|
||||
|
||||
// Warning is a no-op.
|
||||
func (no *NullOutput) Warning(_ string, _ ...any) {}
|
||||
func (no *NullOutput) Warning(_ string, _ ...any) {
|
||||
// Intentionally empty: NullOutput suppresses all output for testing.
|
||||
}
|
||||
|
||||
// Info is a no-op.
|
||||
func (no *NullOutput) Info(_ string, _ ...any) {}
|
||||
func (no *NullOutput) Info(_ string, _ ...any) {
|
||||
// Intentionally empty: NullOutput suppresses all output for testing.
|
||||
}
|
||||
|
||||
// Progress is a no-op.
|
||||
func (no *NullOutput) Progress(_ string, _ ...any) {}
|
||||
func (no *NullOutput) Progress(_ string, _ ...any) {
|
||||
// Intentionally empty: NullOutput suppresses all output for testing.
|
||||
}
|
||||
|
||||
// Bold is a no-op.
|
||||
func (no *NullOutput) Bold(_ string, _ ...any) {}
|
||||
func (no *NullOutput) Bold(_ string, _ ...any) {
|
||||
// Intentionally empty: NullOutput suppresses all output for testing.
|
||||
}
|
||||
|
||||
// Printf is a no-op.
|
||||
func (no *NullOutput) Printf(_ string, _ ...any) {}
|
||||
func (no *NullOutput) Printf(_ string, _ ...any) {
|
||||
// Intentionally empty: NullOutput suppresses all output for testing.
|
||||
}
|
||||
|
||||
// Fprintf is a no-op.
|
||||
func (no *NullOutput) Fprintf(_ *os.File, _ string, _ ...any) {}
|
||||
func (no *NullOutput) Fprintf(_ *os.File, _ string, _ ...any) {
|
||||
// Intentionally empty: NullOutput suppresses all output for testing.
|
||||
}
|
||||
|
||||
// ErrorWithSuggestions is a no-op.
|
||||
func (no *NullOutput) ErrorWithSuggestions(_ *apperrors.ContextualError) {
|
||||
@@ -68,10 +84,13 @@ func (no *NullOutput) ErrorWithContext(
|
||||
_ string,
|
||||
_ map[string]string,
|
||||
) {
|
||||
// Intentionally empty: NullOutput suppresses all output for testing.
|
||||
}
|
||||
|
||||
// ErrorWithSimpleFix is a no-op.
|
||||
func (no *NullOutput) ErrorWithSimpleFix(_, _ string) {}
|
||||
func (no *NullOutput) ErrorWithSimpleFix(_, _ string) {
|
||||
// Intentionally empty: NullOutput suppresses all output for testing.
|
||||
}
|
||||
|
||||
// FormatContextualError returns empty string.
|
||||
func (no *NullOutput) FormatContextualError(_ *apperrors.ContextualError) string {
|
||||
@@ -103,13 +122,19 @@ func (npm *NullProgressManager) CreateProgressBarForFiles(
|
||||
}
|
||||
|
||||
// FinishProgressBar is a no-op.
|
||||
func (npm *NullProgressManager) FinishProgressBar(_ *progressbar.ProgressBar) {}
|
||||
func (npm *NullProgressManager) FinishProgressBar(_ *progressbar.ProgressBar) {
|
||||
// Intentionally empty: NullProgressManager suppresses progress output for testing.
|
||||
}
|
||||
|
||||
// FinishProgressBarWithNewline is a no-op.
|
||||
func (npm *NullProgressManager) FinishProgressBarWithNewline(_ *progressbar.ProgressBar) {}
|
||||
func (npm *NullProgressManager) FinishProgressBarWithNewline(_ *progressbar.ProgressBar) {
|
||||
// Intentionally empty: NullProgressManager suppresses progress output for testing.
|
||||
}
|
||||
|
||||
// UpdateProgressBar is a no-op.
|
||||
func (npm *NullProgressManager) UpdateProgressBar(_ *progressbar.ProgressBar) {}
|
||||
func (npm *NullProgressManager) UpdateProgressBar(_ *progressbar.ProgressBar) {
|
||||
// Intentionally empty: NullProgressManager suppresses progress output for testing.
|
||||
}
|
||||
|
||||
// ProcessWithProgressBar executes the function for each item without progress display.
|
||||
func (npm *NullProgressManager) ProcessWithProgressBar(
|
||||
|
||||
220
internal/testoutput_test.go
Normal file
220
internal/testoutput_test.go
Normal file
@@ -0,0 +1,220 @@
|
||||
package internal
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/schollz/progressbar/v3"
|
||||
|
||||
"github.com/ivuorinen/gh-action-readme/appconstants"
|
||||
"github.com/ivuorinen/gh-action-readme/internal/apperrors"
|
||||
)
|
||||
|
||||
const testFormatString = "test %s %d"
|
||||
|
||||
func TestNullOutput(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
no := NewNullOutput()
|
||||
if no == nil {
|
||||
t.Fatal("NewNullOutput() returned nil")
|
||||
}
|
||||
|
||||
// Test IsQuiet
|
||||
if !no.IsQuiet() {
|
||||
t.Error("NullOutput.IsQuiet() should return true")
|
||||
}
|
||||
|
||||
// Test all no-op methods don't panic
|
||||
no.Success("test")
|
||||
no.Error("test")
|
||||
no.Warning("test")
|
||||
no.Info("test")
|
||||
no.Progress("test")
|
||||
no.Bold("test")
|
||||
no.Printf("test")
|
||||
no.Fprintf(os.Stdout, "test")
|
||||
|
||||
// Test error methods
|
||||
err := apperrors.New(appconstants.ErrCodeUnknown, "test error")
|
||||
no.ErrorWithSuggestions(err)
|
||||
no.ErrorWithContext(appconstants.ErrCodeUnknown, "test", map[string]string{})
|
||||
no.ErrorWithSimpleFix("test", "fix")
|
||||
|
||||
// Test FormatContextualError
|
||||
formatted := no.FormatContextualError(err)
|
||||
if formatted != "" {
|
||||
t.Errorf("NullOutput.FormatContextualError() = %q, want empty string", formatted)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNullProgressManager(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
npm := NewNullProgressManager()
|
||||
if npm == nil {
|
||||
t.Fatal("NewNullProgressManager() returned nil")
|
||||
}
|
||||
|
||||
// Test CreateProgressBar returns nil
|
||||
bar := npm.CreateProgressBar("test", 10)
|
||||
if bar != nil {
|
||||
t.Error("NullProgressManager.CreateProgressBar() should return nil")
|
||||
}
|
||||
|
||||
// Test CreateProgressBarForFiles returns nil
|
||||
bar = npm.CreateProgressBarForFiles("test", []string{"file1", "file2"})
|
||||
if bar != nil {
|
||||
t.Error("NullProgressManager.CreateProgressBarForFiles() should return nil")
|
||||
}
|
||||
|
||||
// Test no-op methods don't panic
|
||||
npm.FinishProgressBar(nil)
|
||||
npm.FinishProgressBarWithNewline(nil)
|
||||
npm.UpdateProgressBar(nil)
|
||||
|
||||
// Test ProcessWithProgressBar executes function for each item
|
||||
var count int
|
||||
items := []string{"item1", "item2", "item3"}
|
||||
npm.ProcessWithProgressBar("test", items, func(_ string, _ *progressbar.ProgressBar) {
|
||||
count++
|
||||
})
|
||||
|
||||
if count != len(items) {
|
||||
t.Errorf("ProcessWithProgressBar processed %d items, want %d", count, len(items))
|
||||
}
|
||||
}
|
||||
|
||||
// TestNullOutputEdgeCases tests NullOutput methods with edge case inputs.
|
||||
func TestNullOutputEdgeCases(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
no := NewNullOutput()
|
||||
|
||||
// Test with empty strings
|
||||
no.Success("")
|
||||
no.Error("")
|
||||
no.Warning("")
|
||||
no.Info("")
|
||||
no.Progress("")
|
||||
no.Bold("")
|
||||
no.Printf("")
|
||||
|
||||
// Test with special characters
|
||||
specialChars := "\n\t\r\x00\a\b\v\f"
|
||||
no.Success(specialChars)
|
||||
no.Error(specialChars)
|
||||
no.Warning(specialChars)
|
||||
no.Info(specialChars)
|
||||
no.Progress(specialChars)
|
||||
no.Bold(specialChars)
|
||||
no.Printf(specialChars)
|
||||
|
||||
// Test with unicode
|
||||
unicode := "🎉 emoji test 你好 мир"
|
||||
no.Success(unicode)
|
||||
no.Error(unicode)
|
||||
no.Warning(unicode)
|
||||
no.Info(unicode)
|
||||
no.Progress(unicode)
|
||||
no.Bold(unicode)
|
||||
no.Printf(unicode)
|
||||
|
||||
// Test with format strings and nil args
|
||||
no.Printf(testFormatString, nil, nil)
|
||||
no.Success(testFormatString, nil, nil)
|
||||
no.Error(testFormatString, nil, nil)
|
||||
|
||||
// Test with multiple args
|
||||
no.Success("test", "arg1", "arg2", "arg3")
|
||||
no.Error("test", 1, 2, 3, 4, 5)
|
||||
no.Printf("test %s %d %v", "str", 42, true)
|
||||
}
|
||||
|
||||
// TestNullOutputErrorMethodsWithNil tests error methods with nil inputs.
|
||||
func TestNullOutputErrorMethodsWithNil(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
no := NewNullOutput()
|
||||
|
||||
// Test with nil error
|
||||
no.ErrorWithSuggestions(nil)
|
||||
no.FormatContextualError(nil)
|
||||
|
||||
// Test with nil context
|
||||
no.ErrorWithContext(appconstants.ErrCodeUnknown, "test", nil)
|
||||
|
||||
// Test with empty context
|
||||
no.ErrorWithContext(appconstants.ErrCodeUnknown, "", map[string]string{})
|
||||
|
||||
// Test with empty simple fix
|
||||
no.ErrorWithSimpleFix("", "")
|
||||
}
|
||||
|
||||
// TestNullProgressManagerEdgeCases tests NullProgressManager with edge cases.
|
||||
func TestNullProgressManagerEdgeCases(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
npm := NewNullProgressManager()
|
||||
|
||||
// Test with empty strings
|
||||
bar := npm.CreateProgressBar("", 0)
|
||||
if bar != nil {
|
||||
t.Error("CreateProgressBar with empty string should return nil")
|
||||
}
|
||||
|
||||
// Test with negative count
|
||||
bar = npm.CreateProgressBar("test", -1)
|
||||
if bar != nil {
|
||||
t.Error("CreateProgressBar with negative count should return nil")
|
||||
}
|
||||
|
||||
// Test with empty file list
|
||||
bar = npm.CreateProgressBarForFiles("test", []string{})
|
||||
if bar != nil {
|
||||
t.Error("CreateProgressBarForFiles with empty list should return nil")
|
||||
}
|
||||
|
||||
// Test with nil file list
|
||||
bar = npm.CreateProgressBarForFiles("test", nil)
|
||||
if bar != nil {
|
||||
t.Error("CreateProgressBarForFiles with nil list should return nil")
|
||||
}
|
||||
|
||||
// Test ProcessWithProgressBar with empty items
|
||||
callCount := 0
|
||||
npm.ProcessWithProgressBar("test", []string{}, func(_ string, _ *progressbar.ProgressBar) {
|
||||
callCount++
|
||||
})
|
||||
if callCount != 0 {
|
||||
t.Errorf("ProcessWithProgressBar with empty items called func %d times, want 0", callCount)
|
||||
}
|
||||
|
||||
// Test ProcessWithProgressBar with nil items
|
||||
callCount = 0
|
||||
npm.ProcessWithProgressBar("test", nil, func(_ string, _ *progressbar.ProgressBar) {
|
||||
callCount++
|
||||
})
|
||||
if callCount != 0 {
|
||||
t.Errorf("ProcessWithProgressBar with nil items called func %d times, want 0", callCount)
|
||||
}
|
||||
}
|
||||
|
||||
// TestNullOutputInterfaceCompliance verifies NullOutput implements CompleteOutput.
|
||||
func TestNullOutputInterfaceCompliance(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var _ CompleteOutput = (*NullOutput)(nil)
|
||||
var _ MessageLogger = (*NullOutput)(nil)
|
||||
var _ ErrorReporter = (*NullOutput)(nil)
|
||||
var _ ErrorFormatter = (*NullOutput)(nil)
|
||||
var _ ProgressReporter = (*NullOutput)(nil)
|
||||
var _ QuietChecker = (*NullOutput)(nil)
|
||||
}
|
||||
|
||||
// TestNullProgressManagerInterfaceCompliance verifies NullProgressManager implements ProgressManager.
|
||||
func TestNullProgressManagerInterfaceCompliance(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var _ ProgressManager = (*NullProgressManager)(nil)
|
||||
}
|
||||
727
internal/validation/strings_mutation_test.go
Normal file
727
internal/validation/strings_mutation_test.go
Normal file
@@ -0,0 +1,727 @@
|
||||
package validation
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/ivuorinen/gh-action-readme/testutil"
|
||||
)
|
||||
|
||||
// urlTestCase represents a single URL parsing test case.
|
||||
type urlTestCase struct {
|
||||
name string
|
||||
url string
|
||||
wantOrg string
|
||||
wantRepo string
|
||||
critical bool
|
||||
description string
|
||||
}
|
||||
|
||||
// makeURLTestCase creates a URL test case with fewer lines of code.
|
||||
func makeURLTestCase(name, url, org, repo string, critical bool, desc string) urlTestCase {
|
||||
return urlTestCase{
|
||||
name: name,
|
||||
url: url,
|
||||
wantOrg: org,
|
||||
wantRepo: repo,
|
||||
critical: critical,
|
||||
description: desc,
|
||||
}
|
||||
}
|
||||
|
||||
// sanitizeTestCase represents a string sanitization test case.
|
||||
type sanitizeTestCase struct {
|
||||
name string
|
||||
input string
|
||||
want string
|
||||
critical bool
|
||||
description string
|
||||
}
|
||||
|
||||
// makeSanitizeTestCase creates a sanitize test case with fewer lines of code.
|
||||
func makeSanitizeTestCase(name, input, want string, critical bool, desc string) sanitizeTestCase {
|
||||
return sanitizeTestCase{
|
||||
name: name,
|
||||
input: input,
|
||||
want: want,
|
||||
critical: critical,
|
||||
description: desc,
|
||||
}
|
||||
}
|
||||
|
||||
// formatTestCase represents a uses statement formatting test case.
|
||||
type formatTestCase struct {
|
||||
name string
|
||||
org string
|
||||
repo string
|
||||
version string
|
||||
want string
|
||||
critical bool
|
||||
description string
|
||||
}
|
||||
|
||||
// makeFormatTestCase creates a format test case with fewer lines of code.
|
||||
func makeFormatTestCase(name, org, repo, version, want string, critical bool, desc string) formatTestCase {
|
||||
return formatTestCase{
|
||||
name: name,
|
||||
org: org,
|
||||
repo: repo,
|
||||
version: version,
|
||||
want: want,
|
||||
critical: critical,
|
||||
description: desc,
|
||||
}
|
||||
}
|
||||
|
||||
// TestParseGitHubURLMutationResistance tests URL parsing for regex and boundary mutations.
|
||||
// Critical mutations to catch:
|
||||
// - Pattern order changes (SSH vs HTTPS precedence)
|
||||
// - len(matches) >= 3 changed to > 3, == 3, etc.
|
||||
// - Return statement modifications (returning wrong indices).
|
||||
func TestParseGitHubURLMutationResistance(t *testing.T) {
|
||||
tests := []urlTestCase{
|
||||
// HTTPS URLs
|
||||
makeURLTestCase(
|
||||
"https_standard",
|
||||
testutil.MutationURLHTTPS,
|
||||
testutil.MutationOrgOctocat,
|
||||
testutil.MutationRepoHelloWorld,
|
||||
false,
|
||||
"Standard HTTPS URL",
|
||||
),
|
||||
makeURLTestCase(
|
||||
"https_with_git_extension",
|
||||
testutil.MutationURLHTTPSGit,
|
||||
testutil.MutationOrgOctocat,
|
||||
testutil.MutationRepoHelloWorld,
|
||||
true,
|
||||
".git extension handled by (?:\\.git)? regex",
|
||||
),
|
||||
|
||||
// SSH URLs
|
||||
makeURLTestCase(
|
||||
"ssh_standard",
|
||||
testutil.MutationURLSSH,
|
||||
testutil.MutationOrgOctocat,
|
||||
testutil.MutationRepoHelloWorld,
|
||||
true,
|
||||
"SSH URL with colon separator ([:/] pattern)",
|
||||
),
|
||||
makeURLTestCase(
|
||||
"ssh_with_git_extension",
|
||||
testutil.MutationURLSSHGit,
|
||||
testutil.MutationOrgOctocat,
|
||||
testutil.MutationRepoHelloWorld,
|
||||
true,
|
||||
"SSH with .git",
|
||||
),
|
||||
|
||||
// Simple format
|
||||
makeURLTestCase(
|
||||
"simple_org_repo",
|
||||
testutil.MutationURLSimple,
|
||||
testutil.MutationOrgOctocat,
|
||||
testutil.MutationRepoHelloWorld,
|
||||
true,
|
||||
"Simple org/repo format (second pattern)",
|
||||
),
|
||||
|
||||
// Edge cases with special characters
|
||||
makeURLTestCase(
|
||||
"org_with_dash",
|
||||
testutil.MutationURLSetupNode,
|
||||
testutil.MutationOrgActions,
|
||||
testutil.MutationRepoSetupNode,
|
||||
false,
|
||||
"Hyphen in repo name",
|
||||
),
|
||||
makeURLTestCase("org_with_number", "org123/repo456", "org123", "repo456", false, "Numbers in org/repo"),
|
||||
|
||||
// Boundary: len(matches) >= 3
|
||||
makeURLTestCase(
|
||||
"exactly_3_matches",
|
||||
"a/b",
|
||||
"a",
|
||||
"b",
|
||||
true,
|
||||
"Minimal valid: exactly 3 matches (full, org, repo)",
|
||||
),
|
||||
|
||||
// Invalid URLs (should return empty)
|
||||
makeURLTestCase(
|
||||
"no_slash_invalid",
|
||||
"octocatHello-World",
|
||||
testutil.MutationStrEmpty,
|
||||
testutil.MutationStrEmpty,
|
||||
true,
|
||||
"No slash separator",
|
||||
),
|
||||
makeURLTestCase(
|
||||
"empty_string",
|
||||
testutil.MutationStrEmpty,
|
||||
testutil.MutationStrEmpty,
|
||||
testutil.MutationStrEmpty,
|
||||
true,
|
||||
"Empty string",
|
||||
),
|
||||
makeURLTestCase(
|
||||
"only_org",
|
||||
"octocat/",
|
||||
testutil.MutationStrEmpty,
|
||||
testutil.MutationStrEmpty,
|
||||
true,
|
||||
"Trailing slash, no repo",
|
||||
),
|
||||
makeURLTestCase(
|
||||
"only_repo",
|
||||
"/Hello-World",
|
||||
testutil.MutationStrEmpty,
|
||||
testutil.MutationStrEmpty,
|
||||
true,
|
||||
"Leading slash, no org",
|
||||
),
|
||||
|
||||
// Pattern precedence tests
|
||||
makeURLTestCase(
|
||||
"github_com_in_middle",
|
||||
testutil.MutationURLGitHubReadme,
|
||||
testutil.MutationOrgIvuorinen,
|
||||
testutil.MutationRepoGhActionReadme,
|
||||
false,
|
||||
"First pattern should match",
|
||||
),
|
||||
|
||||
// Regex capture group tests
|
||||
makeURLTestCase(
|
||||
"multiple_slashes",
|
||||
"octocat/Hello-World/extra",
|
||||
testutil.MutationStrEmpty,
|
||||
testutil.MutationStrEmpty,
|
||||
false,
|
||||
"Extra path segments invalid for simple format",
|
||||
),
|
||||
|
||||
// .git extension edge cases
|
||||
makeURLTestCase(
|
||||
"double_git_extension",
|
||||
"octocat/Hello-World.git.git",
|
||||
testutil.MutationStrEmpty,
|
||||
testutil.MutationStrEmpty,
|
||||
true,
|
||||
"Dots not allowed in repo name by [^/.] pattern",
|
||||
),
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
gotOrg, gotRepo := ParseGitHubURL(tt.url)
|
||||
|
||||
if gotOrg != tt.wantOrg {
|
||||
t.Errorf("ParseGitHubURL(%q) org = %q, want %q (description: %s)",
|
||||
tt.url, gotOrg, tt.wantOrg, tt.description)
|
||||
}
|
||||
if gotRepo != tt.wantRepo {
|
||||
t.Errorf("ParseGitHubURL(%q) repo = %q, want %q (description: %s)",
|
||||
tt.url, gotRepo, tt.wantRepo, tt.description)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestSanitizeActionNameMutationResistance tests string transformation order and operations.
|
||||
// Critical mutations to catch:
|
||||
// - Order of operations (TrimSpace, ReplaceAll, ToLower)
|
||||
// - ReplaceAll vs Replace (all occurrences vs first)
|
||||
// - Wrong replacement string.
|
||||
func TestSanitizeActionNameMutationResistance(t *testing.T) {
|
||||
tests := []sanitizeTestCase{
|
||||
// Basic transformations
|
||||
makeSanitizeTestCase("lowercase_conversion", "UPPERCASE", "uppercase", true, "ToLower applied"),
|
||||
makeSanitizeTestCase(
|
||||
"space_to_dash",
|
||||
testutil.ValidationHelloWorld,
|
||||
testutil.MutationStrHelloWorldDash,
|
||||
true,
|
||||
"ReplaceAll spaces with dashes",
|
||||
),
|
||||
makeSanitizeTestCase("trim_spaces", " hello ", "hello", true, "TrimSpace applied"),
|
||||
|
||||
// Multiple spaces (ReplaceAll vs Replace critical)
|
||||
makeSanitizeTestCase(
|
||||
"multiple_spaces_all_replaced",
|
||||
"hello world test",
|
||||
"hello--world--test",
|
||||
true,
|
||||
"All spaces replaced (ReplaceAll, not Replace)",
|
||||
),
|
||||
makeSanitizeTestCase("three_consecutive_spaces", "a b", "a---b", true, "Each space replaced individually"),
|
||||
|
||||
// Operation order tests
|
||||
makeSanitizeTestCase(
|
||||
"uppercase_with_spaces",
|
||||
"HELLO WORLD",
|
||||
testutil.MutationStrHelloWorldDash,
|
||||
true,
|
||||
"Both lowercase and space replacement",
|
||||
),
|
||||
makeSanitizeTestCase(
|
||||
"leading_trailing_spaces_uppercase",
|
||||
" HELLO WORLD ",
|
||||
testutil.MutationStrHelloWorldDash,
|
||||
true,
|
||||
"All transformations: trim, replace, lowercase",
|
||||
),
|
||||
|
||||
// Edge cases
|
||||
makeSanitizeTestCase(
|
||||
"empty_string",
|
||||
testutil.MutationStrEmpty,
|
||||
testutil.MutationStrEmpty,
|
||||
true,
|
||||
testutil.MutationDescEmptyInput,
|
||||
),
|
||||
makeSanitizeTestCase("only_spaces", " ", testutil.MutationStrEmpty, true, "Only spaces (trimmed to empty)"),
|
||||
makeSanitizeTestCase(
|
||||
"no_changes_needed",
|
||||
"already-sanitized",
|
||||
"already-sanitized",
|
||||
false,
|
||||
"Already in correct format",
|
||||
),
|
||||
|
||||
// Special characters
|
||||
makeSanitizeTestCase(
|
||||
"mixed_case_with_hyphens",
|
||||
testutil.MutationStrSetupNode,
|
||||
"setup-node",
|
||||
false,
|
||||
"Existing hyphens preserved",
|
||||
),
|
||||
makeSanitizeTestCase("underscore_preserved", "hello_world", "hello_world", false, "Underscores not replaced"),
|
||||
makeSanitizeTestCase("numbers_preserved", "Action 123", "action-123", false, "Numbers preserved"),
|
||||
|
||||
// Real-world action names
|
||||
makeSanitizeTestCase(
|
||||
"checkout_action",
|
||||
testutil.MutationStrCheckoutCode,
|
||||
testutil.MutationStrCheckoutCodeDash,
|
||||
false,
|
||||
"Realistic action name",
|
||||
),
|
||||
makeSanitizeTestCase(
|
||||
"setup_go_action",
|
||||
testutil.MutationStrSetupGoEnvironment,
|
||||
testutil.MutationStrSetupGoEnvironmentD,
|
||||
false,
|
||||
"Multi-word action name",
|
||||
),
|
||||
|
||||
// Single character
|
||||
makeSanitizeTestCase("single_char", "A", "a", false, "Single character"),
|
||||
makeSanitizeTestCase("single_space", " ", testutil.MutationStrEmpty, true, "Single space (trimmed)"),
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := SanitizeActionName(tt.input)
|
||||
if got != tt.want {
|
||||
t.Errorf("SanitizeActionName(%q) = %q, want %q (description: %s)",
|
||||
tt.input, got, tt.want, tt.description)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestTrimAndNormalizeMutationResistance tests whitespace normalization.
|
||||
// Critical mutations to catch:
|
||||
// - Regex quantifier changes (\s+ to \s*, \s, etc.)
|
||||
// - TrimSpace removal or reordering
|
||||
// - ReplaceAllString to different methods.
|
||||
func TestTrimAndNormalizeMutationResistance(t *testing.T) {
|
||||
tests := []sanitizeTestCase{
|
||||
// Leading/trailing whitespace
|
||||
makeSanitizeTestCase("leading_whitespace", " hello", "hello", true, "TrimSpace removes leading"),
|
||||
makeSanitizeTestCase("trailing_whitespace", "hello ", "hello", true, "TrimSpace removes trailing"),
|
||||
makeSanitizeTestCase("both_sides_whitespace", " hello ", "hello", true, "TrimSpace removes both sides"),
|
||||
|
||||
// Internal whitespace normalization
|
||||
makeSanitizeTestCase(
|
||||
"double_space",
|
||||
testutil.ValidationHelloWorld,
|
||||
testutil.ValidationHelloWorld,
|
||||
true,
|
||||
"Double space to single (\\s+ pattern)",
|
||||
),
|
||||
makeSanitizeTestCase(
|
||||
"triple_space",
|
||||
"hello world",
|
||||
testutil.ValidationHelloWorld,
|
||||
true,
|
||||
"Triple space to single",
|
||||
),
|
||||
makeSanitizeTestCase(
|
||||
"many_spaces",
|
||||
"hello world",
|
||||
testutil.ValidationHelloWorld,
|
||||
true,
|
||||
"Many spaces to single (+ quantifier)",
|
||||
),
|
||||
|
||||
// Mixed whitespace types
|
||||
makeSanitizeTestCase(
|
||||
"tab_character",
|
||||
"hello\tworld",
|
||||
testutil.ValidationHelloWorld,
|
||||
true,
|
||||
"Tab normalized to space (\\s includes tabs)",
|
||||
),
|
||||
makeSanitizeTestCase(
|
||||
"newline_character",
|
||||
"hello\nworld",
|
||||
testutil.ValidationHelloWorld,
|
||||
true,
|
||||
"Newline normalized to space (\\s includes newlines)",
|
||||
),
|
||||
makeSanitizeTestCase(
|
||||
"carriage_return",
|
||||
"hello\rworld",
|
||||
testutil.ValidationHelloWorld,
|
||||
true,
|
||||
"CR normalized to space",
|
||||
),
|
||||
makeSanitizeTestCase(
|
||||
"mixed_whitespace",
|
||||
"hello \t\n world",
|
||||
testutil.ValidationHelloWorld,
|
||||
true,
|
||||
"Mixed whitespace types to single space",
|
||||
),
|
||||
|
||||
// Combined leading/trailing and internal
|
||||
makeSanitizeTestCase(
|
||||
"all_whitespace_issues",
|
||||
" hello world ",
|
||||
testutil.ValidationHelloWorld,
|
||||
true,
|
||||
"Trim + normalize internal",
|
||||
),
|
||||
|
||||
// Edge cases
|
||||
makeSanitizeTestCase(
|
||||
"empty_string",
|
||||
testutil.MutationStrEmpty,
|
||||
testutil.MutationStrEmpty,
|
||||
true,
|
||||
testutil.MutationDescEmptyInput,
|
||||
),
|
||||
makeSanitizeTestCase("only_spaces", " ", testutil.MutationStrEmpty, true, "Only spaces (trimmed to empty)"),
|
||||
makeSanitizeTestCase(
|
||||
"only_whitespace_mixed",
|
||||
" \t\n\r ",
|
||||
testutil.MutationStrEmpty,
|
||||
true,
|
||||
"Only various whitespace types",
|
||||
),
|
||||
makeSanitizeTestCase("no_whitespace", "hello", "hello", false, "No whitespace to normalize"),
|
||||
makeSanitizeTestCase(
|
||||
"single_space_valid",
|
||||
testutil.ValidationHelloWorld,
|
||||
testutil.ValidationHelloWorld,
|
||||
false,
|
||||
"Already normalized",
|
||||
),
|
||||
|
||||
// Multiple words
|
||||
makeSanitizeTestCase(
|
||||
"three_words_excess_spaces",
|
||||
"one two three",
|
||||
"one two three",
|
||||
false,
|
||||
"Three words with excess spaces",
|
||||
),
|
||||
|
||||
// Unicode whitespace
|
||||
makeSanitizeTestCase(
|
||||
"regular_space",
|
||||
testutil.ValidationHelloWorld,
|
||||
testutil.ValidationHelloWorld,
|
||||
false,
|
||||
"Regular ASCII space",
|
||||
),
|
||||
|
||||
// Quantifier verification (\s+ means one or more)
|
||||
makeSanitizeTestCase("single_space_between", "a b", "a b", true, "Single space not collapsed (need + for >1)"),
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := TrimAndNormalize(tt.input)
|
||||
if got != tt.want {
|
||||
t.Errorf("TrimAndNormalize(%q) = %q, want %q (description: %s)",
|
||||
tt.input, got, tt.want, tt.description)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestFormatUsesStatementMutationResistance tests uses statement formatting logic.
|
||||
// Critical mutations to catch:
|
||||
// - Empty string checks (org == "" changed to !=, etc.)
|
||||
// - || changed to && in empty check
|
||||
// - HasPrefix negation (! added/removed)
|
||||
// - String concatenation order
|
||||
// - Default version "v1" changed.
|
||||
func TestFormatUsesStatementMutationResistance(t *testing.T) {
|
||||
tests := []formatTestCase{
|
||||
// Basic formatting
|
||||
makeFormatTestCase(
|
||||
"basic_with_version",
|
||||
testutil.MutationOrgActions,
|
||||
testutil.ValidationCheckout,
|
||||
testutil.ValidationCheckoutV3,
|
||||
testutil.MutationUsesActionsCheckout,
|
||||
false,
|
||||
"Standard format",
|
||||
),
|
||||
|
||||
// Empty checks (critical)
|
||||
makeFormatTestCase(
|
||||
"empty_org_returns_empty",
|
||||
testutil.MutationStrEmpty,
|
||||
testutil.ValidationCheckout,
|
||||
testutil.ValidationCheckoutV3,
|
||||
testutil.MutationStrEmpty,
|
||||
true,
|
||||
"org == \"\" check",
|
||||
),
|
||||
makeFormatTestCase(
|
||||
"empty_repo_returns_empty",
|
||||
testutil.MutationOrgActions,
|
||||
testutil.MutationStrEmpty,
|
||||
testutil.ValidationCheckoutV3,
|
||||
testutil.MutationStrEmpty,
|
||||
true,
|
||||
"repo == \"\" check",
|
||||
),
|
||||
makeFormatTestCase(
|
||||
"both_empty_returns_empty",
|
||||
testutil.MutationStrEmpty,
|
||||
testutil.MutationStrEmpty,
|
||||
testutil.ValidationCheckoutV3,
|
||||
testutil.MutationStrEmpty,
|
||||
true,
|
||||
"org == \"\" || repo == \"\" (|| operator critical)",
|
||||
),
|
||||
|
||||
// Default version (critical)
|
||||
makeFormatTestCase(
|
||||
"empty_version_defaults_v1",
|
||||
testutil.MutationOrgActions,
|
||||
testutil.ValidationCheckout,
|
||||
testutil.MutationStrEmpty,
|
||||
testutil.MutationUsesActionsCheckoutV1,
|
||||
true,
|
||||
"version == \"\" defaults to \"v1\"",
|
||||
),
|
||||
|
||||
// @ prefix handling (critical)
|
||||
makeFormatTestCase(
|
||||
"version_without_at",
|
||||
testutil.MutationOrgActions,
|
||||
testutil.ValidationCheckout,
|
||||
testutil.ValidationCheckoutV3,
|
||||
testutil.MutationUsesActionsCheckout,
|
||||
true,
|
||||
"@ added when not present (!HasPrefix check)",
|
||||
),
|
||||
makeFormatTestCase(
|
||||
"version_with_at",
|
||||
testutil.MutationOrgActions,
|
||||
testutil.ValidationCheckout,
|
||||
"@v3",
|
||||
testutil.MutationUsesActionsCheckout,
|
||||
true,
|
||||
"@ not duplicated (HasPrefix check)",
|
||||
),
|
||||
makeFormatTestCase(
|
||||
"double_at_if_hasprefix_fails",
|
||||
testutil.MutationOrgActions,
|
||||
testutil.ValidationCheckout,
|
||||
"@@v3",
|
||||
"actions/checkout@@v3",
|
||||
false,
|
||||
"Malformed input with double @",
|
||||
),
|
||||
|
||||
// String concatenation order
|
||||
makeFormatTestCase(
|
||||
"concatenation_order",
|
||||
"org",
|
||||
"repo",
|
||||
"ver",
|
||||
testutil.MutationUsesOrgRepo,
|
||||
true,
|
||||
"Correct concatenation: org + \"/\" + repo + version",
|
||||
),
|
||||
|
||||
// Edge cases
|
||||
makeFormatTestCase("single_char_org_repo", "a", "b", "c", "a/b@c", false, "Minimal valid input"),
|
||||
makeFormatTestCase(
|
||||
"branch_name_version",
|
||||
testutil.MutationOrgActions,
|
||||
testutil.ValidationCheckout,
|
||||
"main",
|
||||
"actions/checkout@main",
|
||||
false,
|
||||
"Branch name as version",
|
||||
),
|
||||
makeFormatTestCase(
|
||||
"sha_version",
|
||||
testutil.MutationOrgActions,
|
||||
testutil.ValidationCheckout,
|
||||
"abc1234567890def",
|
||||
"actions/checkout@abc1234567890def",
|
||||
false,
|
||||
"SHA as version",
|
||||
),
|
||||
|
||||
// Whitespace in inputs
|
||||
makeFormatTestCase(
|
||||
"org_with_spaces_not_trimmed",
|
||||
" actions ",
|
||||
testutil.ValidationCheckout,
|
||||
testutil.ValidationCheckoutV3,
|
||||
" actions /checkout@v3",
|
||||
false,
|
||||
"Spaces preserved (no TrimSpace in function)",
|
||||
),
|
||||
|
||||
// Special characters
|
||||
makeFormatTestCase(
|
||||
"hyphen_in_repo",
|
||||
testutil.MutationOrgActions,
|
||||
testutil.MutationRepoSetupNode,
|
||||
testutil.ValidationCheckoutV3,
|
||||
"actions/setup-node@v3",
|
||||
false,
|
||||
"Hyphen in repo name",
|
||||
),
|
||||
makeFormatTestCase(
|
||||
"at_in_version_position",
|
||||
testutil.MutationOrgActions,
|
||||
testutil.ValidationCheckout,
|
||||
"@v3",
|
||||
testutil.MutationUsesActionsCheckout,
|
||||
true,
|
||||
"Existing @ not duplicated",
|
||||
),
|
||||
|
||||
// Boolean operator mutation detection
|
||||
makeFormatTestCase(
|
||||
"non_empty_org_empty_repo",
|
||||
testutil.MutationOrgActions,
|
||||
testutil.MutationStrEmpty,
|
||||
testutil.ValidationCheckoutV3,
|
||||
testutil.MutationStrEmpty,
|
||||
true,
|
||||
"|| means either empty returns \"\" (not &&)",
|
||||
),
|
||||
makeFormatTestCase(
|
||||
"empty_org_non_empty_repo",
|
||||
testutil.MutationStrEmpty,
|
||||
testutil.ValidationCheckout,
|
||||
testutil.ValidationCheckoutV3,
|
||||
testutil.MutationStrEmpty,
|
||||
true,
|
||||
"|| means either empty returns \"\" (not &&)",
|
||||
),
|
||||
|
||||
// Default version with @ handling
|
||||
makeFormatTestCase(
|
||||
"empty_version_gets_at_prefix",
|
||||
testutil.MutationOrgActions,
|
||||
testutil.ValidationCheckout,
|
||||
testutil.MutationStrEmpty,
|
||||
testutil.MutationUsesActionsCheckoutV1,
|
||||
true,
|
||||
"Empty version: default \"v1\" then @ added",
|
||||
),
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := FormatUsesStatement(tt.org, tt.repo, tt.version)
|
||||
if got != tt.want {
|
||||
t.Errorf("FormatUsesStatement(%q, %q, %q) = %q, want %q (description: %s)",
|
||||
tt.org, tt.repo, tt.version, got, tt.want, tt.description)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestCleanVersionStringMutationResistance tests version cleaning for operation order.
|
||||
// Critical mutations to catch:
|
||||
// - TrimSpace removal
|
||||
// - TrimPrefix removal or wrong prefix
|
||||
// - Operation order (trim then prefix vs prefix then trim).
|
||||
func TestCleanVersionStringMutationResistance(t *testing.T) {
|
||||
tests := []sanitizeTestCase{
|
||||
// v prefix removal
|
||||
makeSanitizeTestCase("v_prefix_removed", "v1.2.3", "1.2.3", true, "TrimPrefix(\"v\") applied"),
|
||||
makeSanitizeTestCase("no_v_prefix_unchanged", "1.2.3", "1.2.3", true, "No v prefix to remove"),
|
||||
|
||||
// Whitespace handling
|
||||
makeSanitizeTestCase("leading_whitespace", " v1.2.3", "1.2.3", true, "TrimSpace before TrimPrefix"),
|
||||
makeSanitizeTestCase("trailing_whitespace", "v1.2.3 ", "1.2.3", true, "TrimSpace applied"),
|
||||
makeSanitizeTestCase("both_whitespace_and_v", " v1.2.3 ", "1.2.3", true, "Both TrimSpace and TrimPrefix"),
|
||||
|
||||
// Operation order critical
|
||||
makeSanitizeTestCase(
|
||||
"whitespace_before_v",
|
||||
" v1.2.3",
|
||||
"1.2.3",
|
||||
true,
|
||||
"TrimSpace must happen before TrimPrefix",
|
||||
),
|
||||
|
||||
// Edge cases
|
||||
makeSanitizeTestCase("only_v", "v", testutil.MutationStrEmpty, true, "Just v becomes empty"),
|
||||
makeSanitizeTestCase(
|
||||
"empty_string",
|
||||
testutil.MutationStrEmpty,
|
||||
testutil.MutationStrEmpty,
|
||||
true,
|
||||
testutil.MutationDescEmptyInput,
|
||||
),
|
||||
makeSanitizeTestCase("only_whitespace", " ", testutil.MutationStrEmpty, true, "Only spaces"),
|
||||
|
||||
// Multiple v's
|
||||
makeSanitizeTestCase(
|
||||
"double_v",
|
||||
"vv1.2.3",
|
||||
"v1.2.3",
|
||||
true,
|
||||
"Only first v removed (TrimPrefix, not ReplaceAll)",
|
||||
),
|
||||
|
||||
// No changes needed
|
||||
makeSanitizeTestCase("already_clean", "1.2.3", "1.2.3", false, "Already clean"),
|
||||
|
||||
// Real-world versions
|
||||
makeSanitizeTestCase("semver_with_v", testutil.MutationVersionV2, "2.5.1", false, "Realistic semver"),
|
||||
makeSanitizeTestCase("semver_no_v", "2.5.1", "2.5.1", false, "Realistic semver without v"),
|
||||
|
||||
// Whitespace variations
|
||||
makeSanitizeTestCase("tab_character", "\tv1.2.3", "1.2.3", true, "Tab handled by TrimSpace"),
|
||||
makeSanitizeTestCase("newline", "v1.2.3\n", "1.2.3", true, "Newline handled by TrimSpace"),
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := CleanVersionString(tt.input)
|
||||
if got != tt.want {
|
||||
t.Errorf("CleanVersionString(%q) = %q, want %q (description: %s)",
|
||||
tt.input, got, tt.want, tt.description)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
491
internal/validation/strings_property_test.go
Normal file
491
internal/validation/strings_property_test.go
Normal file
@@ -0,0 +1,491 @@
|
||||
package validation
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/leanovate/gopter"
|
||||
"github.com/leanovate/gopter/gen"
|
||||
"github.com/leanovate/gopter/prop"
|
||||
)
|
||||
|
||||
// TestFormatUsesStatementProperties verifies properties of uses statement formatting.
|
||||
func TestFormatUsesStatementProperties(t *testing.T) {
|
||||
properties := gopter.NewProperties(nil)
|
||||
registerUsesStatementProperties(properties)
|
||||
properties.TestingRun(t)
|
||||
}
|
||||
|
||||
// registerUsesStatementProperties registers all uses statement property tests.
|
||||
func registerUsesStatementProperties(properties *gopter.Properties) {
|
||||
registerUsesStatementAtSymbolProperty(properties)
|
||||
registerUsesStatementNonEmptyProperty(properties)
|
||||
registerUsesStatementPrefixProperty(properties)
|
||||
registerUsesStatementEmptyInputProperty(properties)
|
||||
registerUsesStatementVersionPrefixProperty(properties)
|
||||
}
|
||||
|
||||
// registerUsesStatementAtSymbolProperty tests that result contains exactly one @ symbol.
|
||||
func registerUsesStatementAtSymbolProperty(properties *gopter.Properties) {
|
||||
properties.Property("uses statement has exactly one @ symbol when non-empty",
|
||||
prop.ForAll(
|
||||
func(org, repo, version string) bool {
|
||||
result := FormatUsesStatement(org, repo, version)
|
||||
if result == "" {
|
||||
return true
|
||||
}
|
||||
|
||||
return strings.Count(result, "@") == 1
|
||||
},
|
||||
gen.AlphaString(),
|
||||
gen.AlphaString(),
|
||||
gen.AlphaString(),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
// registerUsesStatementNonEmptyProperty tests non-empty inputs produce non-empty result.
|
||||
func registerUsesStatementNonEmptyProperty(properties *gopter.Properties) {
|
||||
properties.Property("non-empty org and repo produce non-empty result",
|
||||
prop.ForAll(
|
||||
func(org, repo, version string) bool {
|
||||
if org == "" || repo == "" {
|
||||
return true
|
||||
}
|
||||
|
||||
return FormatUsesStatement(org, repo, version) != ""
|
||||
},
|
||||
gen.AlphaString().SuchThat(func(s string) bool { return s != "" }),
|
||||
gen.AlphaString().SuchThat(func(s string) bool { return s != "" }),
|
||||
gen.AlphaString(),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
// registerUsesStatementPrefixProperty tests result starts with org/repo pattern.
|
||||
func registerUsesStatementPrefixProperty(properties *gopter.Properties) {
|
||||
properties.Property("uses statement starts with org/repo when both non-empty",
|
||||
prop.ForAll(
|
||||
func(org, repo, version string) bool {
|
||||
if org == "" || repo == "" {
|
||||
return true
|
||||
}
|
||||
result := FormatUsesStatement(org, repo, version)
|
||||
|
||||
return strings.HasPrefix(result, org+"/"+repo)
|
||||
},
|
||||
gen.AlphaString().SuchThat(func(s string) bool { return s != "" }),
|
||||
gen.AlphaString().SuchThat(func(s string) bool { return s != "" }),
|
||||
gen.AlphaString(),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
// registerUsesStatementEmptyInputProperty tests empty inputs produce empty result.
|
||||
func registerUsesStatementEmptyInputProperty(properties *gopter.Properties) {
|
||||
properties.Property("empty org or repo produces empty result",
|
||||
prop.ForAll(
|
||||
func(org, repo, version string) bool {
|
||||
if org == "" || repo == "" {
|
||||
return FormatUsesStatement(org, repo, version) == ""
|
||||
}
|
||||
|
||||
return true
|
||||
},
|
||||
gen.AlphaString(),
|
||||
gen.AlphaString(),
|
||||
gen.AlphaString(),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
// registerUsesStatementVersionPrefixProperty tests version part has @ prefix.
|
||||
func registerUsesStatementVersionPrefixProperty(properties *gopter.Properties) {
|
||||
properties.Property("version part in result always has @ prefix",
|
||||
prop.ForAll(
|
||||
func(org, repo, version string) bool {
|
||||
if org == "" || repo == "" {
|
||||
return true
|
||||
}
|
||||
result := FormatUsesStatement(org, repo, version)
|
||||
atIndex := strings.Index(result, "@")
|
||||
if atIndex == -1 {
|
||||
return false
|
||||
}
|
||||
|
||||
return strings.HasPrefix(result, org+"/"+repo+"@")
|
||||
},
|
||||
gen.AlphaString().SuchThat(func(s string) bool { return s != "" }),
|
||||
gen.AlphaString().SuchThat(func(s string) bool { return s != "" }),
|
||||
gen.AlphaString(),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
// TestStringNormalizationProperties verifies idempotency and whitespace properties.
|
||||
func TestStringNormalizationProperties(t *testing.T) {
|
||||
properties := gopter.NewProperties(nil)
|
||||
registerStringNormalizationProperties(properties)
|
||||
properties.TestingRun(t)
|
||||
}
|
||||
|
||||
func registerStringNormalizationProperties(properties *gopter.Properties) {
|
||||
// Property 1: Idempotency - normalizing twice produces same result as once
|
||||
properties.Property("normalization is idempotent",
|
||||
prop.ForAll(
|
||||
func(input string) bool {
|
||||
n1 := TrimAndNormalize(input)
|
||||
n2 := TrimAndNormalize(n1)
|
||||
|
||||
return n1 == n2
|
||||
},
|
||||
gen.AnyString(),
|
||||
),
|
||||
)
|
||||
|
||||
// Property 2: No consecutive spaces in output
|
||||
properties.Property("normalized string has no consecutive spaces",
|
||||
prop.ForAll(
|
||||
func(input string) bool {
|
||||
result := TrimAndNormalize(input)
|
||||
|
||||
return !strings.Contains(result, " ")
|
||||
},
|
||||
gen.AnyString(),
|
||||
),
|
||||
)
|
||||
|
||||
// Property 3: No leading whitespace
|
||||
properties.Property("normalized string has no leading whitespace",
|
||||
prop.ForAll(
|
||||
func(input string) bool {
|
||||
result := TrimAndNormalize(input)
|
||||
|
||||
if result == "" {
|
||||
return true
|
||||
}
|
||||
|
||||
return !strings.HasPrefix(result, " ") &&
|
||||
!strings.HasPrefix(result, "\t") &&
|
||||
!strings.HasPrefix(result, "\n")
|
||||
},
|
||||
gen.AnyString(),
|
||||
),
|
||||
)
|
||||
|
||||
// Property 4: No trailing whitespace
|
||||
properties.Property("normalized string has no trailing whitespace",
|
||||
prop.ForAll(
|
||||
func(input string) bool {
|
||||
result := TrimAndNormalize(input)
|
||||
|
||||
if result == "" {
|
||||
return true
|
||||
}
|
||||
|
||||
return !strings.HasSuffix(result, " ") &&
|
||||
!strings.HasSuffix(result, "\t") &&
|
||||
!strings.HasSuffix(result, "\n")
|
||||
},
|
||||
gen.AnyString(),
|
||||
),
|
||||
)
|
||||
|
||||
// Property 5: All-whitespace input becomes empty
|
||||
properties.Property("whitespace-only input becomes empty",
|
||||
prop.ForAll(
|
||||
func() bool {
|
||||
// Generate whitespace-only strings
|
||||
whitespaceOnly := " \t\n\r "
|
||||
result := TrimAndNormalize(whitespaceOnly)
|
||||
|
||||
return result == ""
|
||||
},
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
// TestVersionCleaningProperties verifies version string cleaning properties.
|
||||
// versionCleaningIdempotentProperty verifies cleaning twice produces same result.
|
||||
func versionCleaningIdempotentProperty(version string) bool {
|
||||
v1 := CleanVersionString(version)
|
||||
v2 := CleanVersionString(v1)
|
||||
|
||||
return v1 == v2
|
||||
}
|
||||
|
||||
// versionRemovesSingleVProperty verifies single 'v' is removed.
|
||||
func versionRemovesSingleVProperty(version string) bool {
|
||||
result := CleanVersionString(version)
|
||||
if result == "" {
|
||||
return true
|
||||
}
|
||||
|
||||
trimmed := strings.TrimSpace(version)
|
||||
if strings.HasPrefix(trimmed, "v") && !strings.HasPrefix(trimmed, "vv") {
|
||||
return !strings.HasPrefix(result, "v")
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// versionHasNoBoundaryWhitespaceProperty verifies no leading/trailing whitespace.
|
||||
func versionHasNoBoundaryWhitespaceProperty(version string) bool {
|
||||
result := CleanVersionString(version)
|
||||
if result == "" {
|
||||
return true
|
||||
}
|
||||
|
||||
return !strings.HasPrefix(result, " ") &&
|
||||
!strings.HasSuffix(result, " ") &&
|
||||
!strings.HasPrefix(result, "\t") &&
|
||||
!strings.HasSuffix(result, "\t")
|
||||
}
|
||||
|
||||
// whitespaceOnlyVersionBecomesEmptyProperty verifies whitespace-only inputs become empty.
|
||||
func whitespaceOnlyVersionBecomesEmptyProperty() bool {
|
||||
whitespaceInputs := []string{" ", "\t\t", "\n", " \t\n "}
|
||||
for _, input := range whitespaceInputs {
|
||||
result := CleanVersionString(input)
|
||||
if result != "" {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// nonVContentPreservedProperty verifies non-v content is preserved and trimmed.
|
||||
func nonVContentPreservedProperty(content string) bool {
|
||||
trimmed := strings.TrimSpace(content)
|
||||
if trimmed == "" || strings.HasPrefix(trimmed, "v") {
|
||||
return true // Skip these cases
|
||||
}
|
||||
|
||||
result := CleanVersionString(content)
|
||||
|
||||
return result == trimmed
|
||||
}
|
||||
|
||||
func TestVersionCleaningProperties(t *testing.T) {
|
||||
properties := gopter.NewProperties(nil)
|
||||
|
||||
// Property 1: Idempotency - cleaning twice produces same result
|
||||
properties.Property("version cleaning is idempotent",
|
||||
prop.ForAll(versionCleaningIdempotentProperty, gen.AnyString()),
|
||||
)
|
||||
|
||||
// Property 2: Result never starts with single 'v' (TrimPrefix removes only one)
|
||||
properties.Property("cleaned version removes single leading v",
|
||||
prop.ForAll(versionRemovesSingleVProperty, gen.AnyString()),
|
||||
)
|
||||
|
||||
// Property 3: No leading/trailing whitespace in result
|
||||
properties.Property("cleaned version has no boundary whitespace",
|
||||
prop.ForAll(versionHasNoBoundaryWhitespaceProperty, gen.AnyString()),
|
||||
)
|
||||
|
||||
// Property 4: Whitespace-only input becomes empty
|
||||
properties.Property("whitespace-only version becomes empty",
|
||||
prop.ForAll(whitespaceOnlyVersionBecomesEmptyProperty),
|
||||
)
|
||||
|
||||
// Property 5: Preserves non-v content and trims whitespace
|
||||
properties.Property("non-v content is preserved",
|
||||
prop.ForAll(
|
||||
nonVContentPreservedProperty,
|
||||
gen.OneGenOf(
|
||||
gen.AlphaString(),
|
||||
gen.AlphaString().Map(func(s string) string { return " " + s }),
|
||||
gen.AlphaString().Map(func(s string) string { return s + " " }),
|
||||
gen.AlphaString().Map(func(s string) string { return " " + s + " " }),
|
||||
gen.AlphaString().Map(func(s string) string { return "\t" + s + "\n" }),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
properties.TestingRun(t)
|
||||
}
|
||||
|
||||
// TestSanitizeActionNameProperties verifies action name sanitization properties.
|
||||
func TestSanitizeActionNameProperties(t *testing.T) {
|
||||
properties := gopter.NewProperties(nil)
|
||||
|
||||
// Property 1: Result is always lowercase
|
||||
properties.Property("sanitized name is always lowercase",
|
||||
prop.ForAll(
|
||||
func(name string) bool {
|
||||
result := SanitizeActionName(name)
|
||||
|
||||
return result == strings.ToLower(result)
|
||||
},
|
||||
gen.AnyString(),
|
||||
),
|
||||
)
|
||||
|
||||
// Property 2: No spaces in result
|
||||
properties.Property("sanitized name has no spaces",
|
||||
prop.ForAll(
|
||||
func(name string) bool {
|
||||
result := SanitizeActionName(name)
|
||||
|
||||
return !strings.Contains(result, " ")
|
||||
},
|
||||
gen.AnyString(),
|
||||
),
|
||||
)
|
||||
|
||||
// Property 3: Idempotency
|
||||
properties.Property("sanitization is idempotent",
|
||||
prop.ForAll(
|
||||
func(name string) bool {
|
||||
s1 := SanitizeActionName(name)
|
||||
s2 := SanitizeActionName(s1)
|
||||
|
||||
return s1 == s2
|
||||
},
|
||||
gen.AnyString(),
|
||||
),
|
||||
)
|
||||
|
||||
// Property 4: Whitespace-only input becomes empty
|
||||
properties.Property("whitespace-only input becomes empty",
|
||||
prop.ForAll(
|
||||
func() bool {
|
||||
whitespaceInputs := []string{" ", "\t\t", " \n "}
|
||||
for _, input := range whitespaceInputs {
|
||||
result := SanitizeActionName(input)
|
||||
if result != "" {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
// Property 5: Spaces become hyphens
|
||||
properties.Property("spaces are converted to hyphens",
|
||||
prop.ForAll(
|
||||
func(word1 string, word2 string) bool {
|
||||
// Only test when words are non-empty and don't contain spaces
|
||||
if word1 == "" || word2 == "" ||
|
||||
strings.Contains(word1, " ") ||
|
||||
strings.Contains(word2, " ") {
|
||||
return true
|
||||
}
|
||||
|
||||
input := word1 + " " + word2
|
||||
result := SanitizeActionName(input)
|
||||
|
||||
// Result should contain a hyphen where the space was
|
||||
expectedPart1 := strings.ToLower(word1)
|
||||
expectedPart2 := strings.ToLower(word2)
|
||||
expected := expectedPart1 + "-" + expectedPart2
|
||||
|
||||
return result == expected
|
||||
},
|
||||
gen.AlphaString().SuchThat(func(s string) bool { return len(s) > 0 && !strings.Contains(s, " ") }),
|
||||
gen.AlphaString().SuchThat(func(s string) bool { return len(s) > 0 && !strings.Contains(s, " ") }),
|
||||
),
|
||||
)
|
||||
|
||||
properties.TestingRun(t)
|
||||
}
|
||||
|
||||
// TestParseGitHubURLProperties verifies URL parsing properties.
|
||||
func TestParseGitHubURLProperties(t *testing.T) {
|
||||
properties := gopter.NewProperties(nil)
|
||||
registerGitHubURLProperties(properties)
|
||||
properties.TestingRun(t)
|
||||
}
|
||||
|
||||
// registerGitHubURLProperties registers all GitHub URL parsing property tests.
|
||||
func registerGitHubURLProperties(properties *gopter.Properties) {
|
||||
registerGitHubURLEmptyInputProperty(properties)
|
||||
registerGitHubURLSimpleFormatProperty(properties)
|
||||
registerGitHubURLNoSlashesProperty(properties)
|
||||
registerGitHubURLInvalidInputProperty(properties)
|
||||
registerGitHubURLConsistencyProperty(properties)
|
||||
}
|
||||
|
||||
// registerGitHubURLEmptyInputProperty tests empty URL produces empty results.
|
||||
func registerGitHubURLEmptyInputProperty(properties *gopter.Properties) {
|
||||
properties.Property("empty URL produces empty org and repo",
|
||||
prop.ForAll(
|
||||
func() bool {
|
||||
org, repo := ParseGitHubURL("")
|
||||
|
||||
return org == "" && repo == ""
|
||||
},
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
// registerGitHubURLSimpleFormatProperty tests simple org/repo format parsing.
|
||||
func registerGitHubURLSimpleFormatProperty(properties *gopter.Properties) {
|
||||
properties.Property("simple org/repo format always parses correctly",
|
||||
prop.ForAll(
|
||||
func(org, repo string) bool {
|
||||
if org == "" || repo == "" || strings.Contains(org, "/") || strings.Contains(repo, "/") {
|
||||
return true
|
||||
}
|
||||
gotOrg, gotRepo := ParseGitHubURL(org + "/" + repo)
|
||||
|
||||
return gotOrg == org && gotRepo == repo
|
||||
},
|
||||
gen.AlphaString().SuchThat(func(s string) bool {
|
||||
return len(s) > 0 && !strings.Contains(s, "/") && !strings.Contains(s, ".")
|
||||
}),
|
||||
gen.AlphaString().SuchThat(func(s string) bool {
|
||||
return len(s) > 0 && !strings.Contains(s, "/")
|
||||
}),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
// registerGitHubURLNoSlashesProperty tests parsed results never contain slashes.
|
||||
func registerGitHubURLNoSlashesProperty(properties *gopter.Properties) {
|
||||
properties.Property("parsed org and repo never contain slashes",
|
||||
prop.ForAll(
|
||||
func(url string) bool {
|
||||
org, repo := ParseGitHubURL(url)
|
||||
|
||||
return !strings.Contains(org, "/") && !strings.Contains(repo, "/")
|
||||
},
|
||||
gen.AnyString(),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
// registerGitHubURLInvalidInputProperty tests invalid URLs produce empty results.
|
||||
func registerGitHubURLInvalidInputProperty(properties *gopter.Properties) {
|
||||
properties.Property("URLs without slash produce empty result",
|
||||
prop.ForAll(
|
||||
func(url string) bool {
|
||||
if strings.Contains(url, "/") || strings.Contains(url, "github.com") {
|
||||
return true
|
||||
}
|
||||
org, repo := ParseGitHubURL(url)
|
||||
|
||||
return org == "" && repo == ""
|
||||
},
|
||||
gen.AlphaString(),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
// registerGitHubURLConsistencyProperty tests org and repo are both empty or both non-empty.
|
||||
func registerGitHubURLConsistencyProperty(properties *gopter.Properties) {
|
||||
properties.Property("org and repo are both empty or both non-empty",
|
||||
prop.ForAll(
|
||||
func(url string) bool {
|
||||
org, repo := ParseGitHubURL(url)
|
||||
|
||||
return (org == "" && repo == "") || (org != "" && repo != "")
|
||||
},
|
||||
gen.AnyString(),
|
||||
),
|
||||
)
|
||||
}
|
||||
146
internal/validation/strings_test.go
Normal file
146
internal/validation/strings_test.go
Normal file
@@ -0,0 +1,146 @@
|
||||
package validation
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/ivuorinen/gh-action-readme/testutil"
|
||||
)
|
||||
|
||||
// TestTrimAndNormalize tests the TrimAndNormalize function.
|
||||
func TestTrimAndNormalize(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []testutil.StringTestCase{
|
||||
{
|
||||
Name: "no whitespace",
|
||||
Input: "test",
|
||||
Want: "test",
|
||||
},
|
||||
{
|
||||
Name: "leading and trailing whitespace",
|
||||
Input: " test ",
|
||||
Want: "test",
|
||||
},
|
||||
{
|
||||
Name: "multiple internal spaces",
|
||||
Input: "hello world",
|
||||
Want: testutil.ValidationHelloWorld,
|
||||
},
|
||||
{
|
||||
Name: "mixed whitespace",
|
||||
Input: " hello world ",
|
||||
Want: testutil.ValidationHelloWorld,
|
||||
},
|
||||
{
|
||||
Name: "newlines and tabs",
|
||||
Input: "hello\n\t\tworld",
|
||||
Want: testutil.ValidationHelloWorld,
|
||||
},
|
||||
{
|
||||
Name: "empty string",
|
||||
Input: "",
|
||||
Want: "",
|
||||
},
|
||||
{
|
||||
Name: "whitespace only",
|
||||
Input: " \n\t ",
|
||||
Want: "",
|
||||
},
|
||||
{
|
||||
Name: "multiple lines",
|
||||
Input: "line one\n line two\n line three",
|
||||
Want: "line one line two line three",
|
||||
},
|
||||
}
|
||||
|
||||
testutil.RunStringTests(t, tests, TrimAndNormalize)
|
||||
}
|
||||
|
||||
// TestFormatUsesStatement tests the FormatUsesStatement function.
|
||||
func TestFormatUsesStatement(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
org string
|
||||
repo string
|
||||
version string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "full statement with version",
|
||||
org: "actions",
|
||||
repo: "checkout",
|
||||
version: "v3",
|
||||
want: testutil.TestActionCheckoutV3,
|
||||
},
|
||||
{
|
||||
name: "without version defaults to v1",
|
||||
org: "actions",
|
||||
repo: "setup-node",
|
||||
version: "",
|
||||
want: "actions/setup-node@v1",
|
||||
},
|
||||
{
|
||||
name: "version with @ prefix",
|
||||
org: "actions",
|
||||
repo: "cache",
|
||||
version: "@v2",
|
||||
want: "actions/cache@v2",
|
||||
},
|
||||
{
|
||||
name: "version without @ prefix",
|
||||
org: "actions",
|
||||
repo: "upload-artifact",
|
||||
version: "v4",
|
||||
want: "actions/upload-artifact@v4",
|
||||
},
|
||||
{
|
||||
name: "empty org returns empty",
|
||||
org: "",
|
||||
repo: "checkout",
|
||||
version: "v3",
|
||||
want: "",
|
||||
},
|
||||
{
|
||||
name: "empty repo returns empty",
|
||||
org: "actions",
|
||||
repo: "",
|
||||
version: "v3",
|
||||
want: "",
|
||||
},
|
||||
{
|
||||
name: "both org and repo empty",
|
||||
org: "",
|
||||
repo: "",
|
||||
version: "v3",
|
||||
want: "",
|
||||
},
|
||||
{
|
||||
name: "sha as version",
|
||||
org: "actions",
|
||||
repo: "checkout",
|
||||
version: "abc123def456",
|
||||
want: "actions/checkout@abc123def456",
|
||||
},
|
||||
{
|
||||
name: "main branch as version",
|
||||
org: "actions",
|
||||
repo: "checkout",
|
||||
version: "main",
|
||||
want: "actions/checkout@main",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
got := FormatUsesStatement(tt.org, tt.repo, tt.version)
|
||||
if got != tt.want {
|
||||
t.Errorf("FormatUsesStatement(%q, %q, %q) = %q, want %q",
|
||||
tt.org, tt.repo, tt.version, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
433
internal/validation/validation_mutation_test.go
Normal file
433
internal/validation/validation_mutation_test.go
Normal file
@@ -0,0 +1,433 @@
|
||||
package validation
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/ivuorinen/gh-action-readme/testutil"
|
||||
)
|
||||
|
||||
// Test case helpers - reduce duplication in table-driven tests
|
||||
|
||||
// shaTestCase represents a SHA validation test case.
|
||||
type shaTestCase struct {
|
||||
name string
|
||||
version string
|
||||
want bool
|
||||
critical bool
|
||||
description string
|
||||
}
|
||||
|
||||
// makeSHATestCase constructs a SHA test case.
|
||||
func makeSHATestCase(name, version string, want, critical bool, desc string) shaTestCase {
|
||||
return shaTestCase{
|
||||
name: name,
|
||||
version: version,
|
||||
want: want,
|
||||
critical: critical,
|
||||
description: desc,
|
||||
}
|
||||
}
|
||||
|
||||
// semverTestCase represents a semantic version validation test case.
|
||||
type semverTestCase struct {
|
||||
name string
|
||||
version string
|
||||
want bool
|
||||
critical bool
|
||||
description string
|
||||
}
|
||||
|
||||
// makeSemverTestCase constructs a semantic version test case.
|
||||
func makeSemverTestCase(name, version string, want, critical bool, desc string) semverTestCase {
|
||||
return semverTestCase{
|
||||
name: name,
|
||||
version: version,
|
||||
want: want,
|
||||
critical: critical,
|
||||
description: desc,
|
||||
}
|
||||
}
|
||||
|
||||
// pinnedTestCase represents a version pinning test case.
|
||||
type pinnedTestCase struct {
|
||||
name string
|
||||
version string
|
||||
want bool
|
||||
critical bool
|
||||
description string
|
||||
}
|
||||
|
||||
// makePinnedTestCase constructs a version pinning test case.
|
||||
func makePinnedTestCase(name, version string, want, critical bool, desc string) pinnedTestCase {
|
||||
return pinnedTestCase{
|
||||
name: name,
|
||||
version: version,
|
||||
want: want,
|
||||
critical: critical,
|
||||
description: desc,
|
||||
}
|
||||
}
|
||||
|
||||
// TestIsCommitSHAMutationResistance tests SHA validation for boundary mutations.
|
||||
// Critical mutations to catch:
|
||||
// - len(version) >= 7 changed to > 7 or >= 8
|
||||
// - Regex pattern changes (e.g., + to *, removal of quantifiers).
|
||||
func TestIsCommitSHAMutationResistance(t *testing.T) {
|
||||
tests := []shaTestCase{
|
||||
// Boundary: len >= 7
|
||||
makeSHATestCase("boundary_7_chars_valid", "abc1234", true, true, "Exactly 7 chars (boundary for >= 7)"),
|
||||
makeSHATestCase("boundary_6_chars_invalid", "abc123", false, true, "6 chars should fail (< 7)"),
|
||||
makeSHATestCase("boundary_8_chars_valid", "abc12345", true, false, "8 chars valid"),
|
||||
|
||||
// Boundary: full SHA (40 chars)
|
||||
makeSHATestCase("boundary_40_chars_valid", strings.Repeat("a", 40), true, true, "Full 40-char SHA"),
|
||||
makeSHATestCase(
|
||||
"boundary_39_chars_valid_short_sha",
|
||||
strings.Repeat("a", 39),
|
||||
true,
|
||||
false,
|
||||
"39 chars still valid as short SHA",
|
||||
),
|
||||
makeSHATestCase(
|
||||
"boundary_41_chars_invalid_too_long",
|
||||
strings.Repeat("a", 41),
|
||||
false,
|
||||
true,
|
||||
"41 chars exceeds SHA length",
|
||||
),
|
||||
|
||||
// Hex character validation (regex critical)
|
||||
makeSHATestCase("all_hex_chars_valid", "abcdef0123456789", true, false, "All hex chars"),
|
||||
makeSHATestCase(
|
||||
"uppercase_hex_invalid",
|
||||
"ABCDEF0",
|
||||
false,
|
||||
true,
|
||||
"Uppercase hex chars (regex only accepts [a-f], not [A-F])",
|
||||
),
|
||||
makeSHATestCase(
|
||||
"mixed_case_hex_invalid",
|
||||
"AbCdEf0",
|
||||
false,
|
||||
true,
|
||||
"Mixed case hex (regex only accepts lowercase)",
|
||||
),
|
||||
makeSHATestCase("non_hex_char_g_invalid", "abcdefg", false, true, "Contains 'g' (not hex)"),
|
||||
makeSHATestCase("non_hex_char_z_invalid", "abcdefz", false, true, "Contains 'z' (not hex)"),
|
||||
makeSHATestCase("special_char_invalid", "abc-def", false, true, "Contains dash"),
|
||||
|
||||
// Empty/whitespace
|
||||
makeSHATestCase("empty_string_invalid", "", false, true, "Empty string (len < 7)"),
|
||||
makeSHATestCase("whitespace_invalid", " ", false, false, "Whitespace only"),
|
||||
|
||||
// Real-world SHA examples
|
||||
makeSHATestCase("real_short_sha", "abc1234", true, false, "Realistic 7-char short SHA"),
|
||||
makeSHATestCase("real_full_sha", "1234567890abcdef1234567890abcdef12345678", true, false, "Realistic full SHA"),
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := IsCommitSHA(tt.version)
|
||||
if got != tt.want {
|
||||
t.Errorf("IsCommitSHA(%q) = %v, want %v (description: %s)",
|
||||
tt.version, got, tt.want, tt.description)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestIsSemanticVersionMutationResistance tests semver validation for regex mutations.
|
||||
// Critical mutations to catch:
|
||||
// - Quantifier changes (? to *, + to *, removal of ?)
|
||||
// - Part removal (prerelease, build metadata)
|
||||
// - Anchor removal (^ or $).
|
||||
func TestIsSemanticVersionMutationResistance(t *testing.T) {
|
||||
tests := []semverTestCase{
|
||||
// Basic semver
|
||||
makeSemverTestCase("basic_semver", "1.2.3", true, false, "Basic X.Y.Z"),
|
||||
makeSemverTestCase(
|
||||
"basic_semver_with_v",
|
||||
testutil.TestVersionSemantic,
|
||||
true,
|
||||
true,
|
||||
"v prefix optional (v? quantifier)",
|
||||
),
|
||||
|
||||
// Missing parts (should fail)
|
||||
makeSemverTestCase("missing_patch_invalid", "1.2", false, true, "Missing patch version"),
|
||||
makeSemverTestCase("missing_minor_patch_invalid", "1", false, true, "Only major version"),
|
||||
makeSemverTestCase(
|
||||
"extra_parts_invalid",
|
||||
testutil.MutationSemverInvalidExtraParts,
|
||||
false,
|
||||
true,
|
||||
"Too many parts (no $ anchor would allow this)",
|
||||
),
|
||||
|
||||
// Prerelease versions (optional part)
|
||||
makeSemverTestCase("prerelease_alpha", "1.2.3-alpha", true, true, "Prerelease part (- with ? quantifier)"),
|
||||
makeSemverTestCase("prerelease_alpha_1", "1.2.3-alpha.1", true, true, "Prerelease with dot"),
|
||||
makeSemverTestCase("prerelease_multiple_parts", "1.2.3-alpha.beta.1", true, false, "Multiple prerelease parts"),
|
||||
makeSemverTestCase(
|
||||
"empty_prerelease_invalid",
|
||||
testutil.MutationSemverEmptyPrerelease,
|
||||
false,
|
||||
true,
|
||||
"Dash with no prerelease (+ requires content)",
|
||||
),
|
||||
|
||||
// Build metadata (optional part)
|
||||
makeSemverTestCase("build_metadata", "1.2.3+build.123", true, true, "Build metadata (+ with ? quantifier)"),
|
||||
makeSemverTestCase("empty_build_invalid", "1.2.3+", false, true, "Plus with no build metadata"),
|
||||
makeSemverTestCase(
|
||||
"build_metadata_only_numbers",
|
||||
testutil.MutationSemverBuildOnlyNumbers,
|
||||
true,
|
||||
false,
|
||||
"Build with only numbers",
|
||||
),
|
||||
|
||||
// Combined prerelease and build
|
||||
makeSemverTestCase("prerelease_and_build", "1.2.3-alpha+build.123", true, false, "Both prerelease and build"),
|
||||
|
||||
// Zero versions
|
||||
makeSemverTestCase("zero_version", "0.0.0", true, false, "All zeros valid"),
|
||||
makeSemverTestCase("zero_major", "0.1.2", true, false, "Zero major valid"),
|
||||
|
||||
// Large numbers
|
||||
makeSemverTestCase("large_numbers", "100.200.300", true, false, "Multi-digit versions"),
|
||||
|
||||
// Invalid formats
|
||||
makeSemverTestCase("no_dots_invalid", "123", false, true, "No dots"),
|
||||
makeSemverTestCase("letters_in_version_invalid", "a.b.c", false, true, "Letters in version numbers"),
|
||||
makeSemverTestCase("leading_zero_technically_valid", "01.02.03", true, false, "Leading zeros (regex allows)"),
|
||||
|
||||
// v prefix edge cases
|
||||
makeSemverTestCase(
|
||||
"double_v_invalid",
|
||||
testutil.MutationSemverDoubleV,
|
||||
false,
|
||||
true,
|
||||
"Double v prefix (v? means 0 or 1)",
|
||||
),
|
||||
makeSemverTestCase(
|
||||
"uppercase_V_invalid",
|
||||
testutil.MutationSemverUppercaseV,
|
||||
false,
|
||||
true,
|
||||
"Uppercase V not allowed",
|
||||
),
|
||||
|
||||
// Whitespace
|
||||
makeSemverTestCase(
|
||||
"leading_whitespace_invalid",
|
||||
testutil.MutationSemverLeadingSpace,
|
||||
false,
|
||||
true,
|
||||
"Leading space (^ anchor)",
|
||||
),
|
||||
makeSemverTestCase(
|
||||
"trailing_whitespace_invalid",
|
||||
testutil.MutationSemverTrailingSpace,
|
||||
false,
|
||||
true,
|
||||
"Trailing space ($ anchor)",
|
||||
),
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := IsSemanticVersion(tt.version)
|
||||
if got != tt.want {
|
||||
t.Errorf("IsSemanticVersion(%q) = %v, want %v (description: %s)",
|
||||
tt.version, got, tt.want, tt.description)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestIsVersionPinnedMutationResistance tests version pinning logic for operator mutations.
|
||||
// Critical mutations to catch:
|
||||
// - || changed to && (complete logic inversion)
|
||||
// - && changed to || in SHA check
|
||||
// - == 40 changed to != 40, > 40, < 40, >= 40, <= 40
|
||||
// - Removal of IsSemanticVersion() or IsCommitSHA() calls.
|
||||
func TestIsVersionPinnedMutationResistance(t *testing.T) {
|
||||
tests := []pinnedTestCase{
|
||||
// Semantic version cases (first part of ||)
|
||||
makePinnedTestCase("semver_is_pinned", "v1.2.3", true, true, "Semver satisfies first condition"),
|
||||
makePinnedTestCase("semver_no_v_is_pinned", "1.2.3", true, true, "Semver without v"),
|
||||
|
||||
// Full SHA cases (second part of ||)
|
||||
makePinnedTestCase(
|
||||
"full_40_char_sha_is_pinned",
|
||||
strings.Repeat("a", 40),
|
||||
true,
|
||||
true,
|
||||
"40-char SHA satisfies: IsCommitSHA() && len == 40",
|
||||
),
|
||||
makePinnedTestCase(
|
||||
"39_char_sha_not_pinned",
|
||||
strings.Repeat("a", 39),
|
||||
false,
|
||||
true,
|
||||
"39-char SHA fails: len != 40 (critical boundary)",
|
||||
),
|
||||
makePinnedTestCase(
|
||||
"41_char_not_sha_not_pinned",
|
||||
strings.Repeat("a", 41),
|
||||
false,
|
||||
true,
|
||||
"41 chars: not valid SHA && len != 40",
|
||||
),
|
||||
|
||||
// Short SHA cases (should not be pinned)
|
||||
makePinnedTestCase(
|
||||
"7_char_sha_not_pinned",
|
||||
"abcdef0",
|
||||
false,
|
||||
true,
|
||||
"7-char SHA: IsCommitSHA() true but len != 40",
|
||||
),
|
||||
makePinnedTestCase(
|
||||
"20_char_sha_not_pinned",
|
||||
strings.Repeat("a", 20),
|
||||
false,
|
||||
true,
|
||||
"20-char SHA: IsCommitSHA() true but len != 40",
|
||||
),
|
||||
|
||||
// Major-only versions (not pinned)
|
||||
makePinnedTestCase("major_only_not_pinned", "v1", false, true, "v1 not semver, not pinned"),
|
||||
makePinnedTestCase(
|
||||
"major_minor_not_pinned",
|
||||
"v1.2",
|
||||
false,
|
||||
true,
|
||||
"v1.2 not semver (missing patch), not pinned",
|
||||
),
|
||||
|
||||
// Branch names (not pinned)
|
||||
makePinnedTestCase("branch_main_not_pinned", "main", false, true, "Branch name: not semver, not SHA"),
|
||||
makePinnedTestCase("branch_develop_not_pinned", "develop", false, false, "Branch name: not semver, not SHA"),
|
||||
|
||||
// Edge cases with prerelease/build
|
||||
makePinnedTestCase(
|
||||
"semver_with_prerelease_pinned",
|
||||
"1.2.3-alpha",
|
||||
true,
|
||||
false,
|
||||
"Semver with prerelease still pinned",
|
||||
),
|
||||
makePinnedTestCase(
|
||||
"semver_with_build_pinned",
|
||||
"1.2.3+build",
|
||||
true,
|
||||
false,
|
||||
"Semver with build metadata still pinned",
|
||||
),
|
||||
|
||||
// Empty/invalid
|
||||
makePinnedTestCase("empty_not_pinned", "", false, true, "Empty string: not semver, not SHA"),
|
||||
|
||||
// Operator mutation detection tests
|
||||
makePinnedTestCase(
|
||||
"exactly_40_boundary",
|
||||
strings.Repeat("a", 40),
|
||||
true,
|
||||
true,
|
||||
"Exactly 40: tests == boundary (not !=, <, >, <=, >=)",
|
||||
),
|
||||
makePinnedTestCase(
|
||||
"40_char_non_hex_not_sha",
|
||||
strings.Repeat("z", 40),
|
||||
false,
|
||||
true,
|
||||
"40 chars but not hex: IsCommitSHA() false, so && fails",
|
||||
),
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := IsVersionPinned(tt.version)
|
||||
if got != tt.want {
|
||||
t.Errorf("IsVersionPinned(%q) = %v, want %v (description: %s)",
|
||||
tt.version, got, tt.want, tt.description)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestVersionValidationLogicCombinations tests the interaction between validation
|
||||
// functions to catch mutations in boolean logic.
|
||||
func TestVersionValidationLogicCombinations(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
version string
|
||||
isSHA bool
|
||||
isSemver bool
|
||||
isPinned bool
|
||||
description string
|
||||
}{
|
||||
{
|
||||
name: "full_sha_all_true",
|
||||
version: strings.Repeat("a", 40),
|
||||
isSHA: true,
|
||||
isSemver: false,
|
||||
isPinned: true,
|
||||
description: "40-char SHA: SHA && pinned, not semver",
|
||||
},
|
||||
{
|
||||
name: "short_sha_not_pinned",
|
||||
version: "abcdef0",
|
||||
isSHA: true,
|
||||
isSemver: false,
|
||||
isPinned: false,
|
||||
description: "7-char SHA: SHA but not pinned",
|
||||
},
|
||||
{
|
||||
name: "semver_all_relevant_true",
|
||||
version: "v1.2.3",
|
||||
isSHA: false,
|
||||
isSemver: true,
|
||||
isPinned: true,
|
||||
description: "Semver: not SHA, is semver, is pinned",
|
||||
},
|
||||
{
|
||||
name: "branch_all_false",
|
||||
version: "main",
|
||||
isSHA: false,
|
||||
isSemver: false,
|
||||
isPinned: false,
|
||||
description: "Branch: nothing true",
|
||||
},
|
||||
{
|
||||
name: "v1_not_semver_not_pinned",
|
||||
version: "v1",
|
||||
isSHA: false,
|
||||
isSemver: false,
|
||||
isPinned: false,
|
||||
description: "Major-only: not valid semver",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
gotSHA := IsCommitSHA(tt.version)
|
||||
gotSemver := IsSemanticVersion(tt.version)
|
||||
gotPinned := IsVersionPinned(tt.version)
|
||||
|
||||
if gotSHA != tt.isSHA {
|
||||
t.Errorf("IsCommitSHA(%q) = %v, want %v", tt.version, gotSHA, tt.isSHA)
|
||||
}
|
||||
if gotSemver != tt.isSemver {
|
||||
t.Errorf("IsSemanticVersion(%q) = %v, want %v", tt.version, gotSemver, tt.isSemver)
|
||||
}
|
||||
if gotPinned != tt.isPinned {
|
||||
t.Errorf("IsVersionPinned(%q) = %v, want %v (description: %s)",
|
||||
tt.version, gotPinned, tt.isPinned, tt.description)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,9 @@
|
||||
package validation
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/ivuorinen/gh-action-readme/appconstants"
|
||||
"github.com/ivuorinen/gh-action-readme/testutil"
|
||||
)
|
||||
|
||||
@@ -23,7 +21,7 @@ func TestValidateActionYMLPath(t *testing.T) {
|
||||
setupFunc: func(t *testing.T, tmpDir string) string {
|
||||
t.Helper()
|
||||
|
||||
return testutil.WriteActionFixture(t, tmpDir, appconstants.TestFixtureJavaScriptSimple)
|
||||
return testutil.WriteActionFixture(t, tmpDir, testutil.TestFixtureJavaScriptSimple)
|
||||
},
|
||||
expectError: false,
|
||||
},
|
||||
@@ -32,7 +30,7 @@ func TestValidateActionYMLPath(t *testing.T) {
|
||||
setupFunc: func(t *testing.T, tmpDir string) string {
|
||||
t.Helper()
|
||||
|
||||
return testutil.WriteActionFixtureAs(t, tmpDir, "action.yaml", appconstants.TestFixtureMinimalAction)
|
||||
return testutil.WriteActionFixtureAs(t, tmpDir, "action.yaml", testutil.TestFixtureMinimalAction)
|
||||
},
|
||||
expectError: false,
|
||||
},
|
||||
@@ -48,7 +46,7 @@ func TestValidateActionYMLPath(t *testing.T) {
|
||||
setupFunc: func(t *testing.T, tmpDir string) string {
|
||||
t.Helper()
|
||||
|
||||
return testutil.WriteActionFixtureAs(t, tmpDir, "action.txt", appconstants.TestFixtureJavaScriptSimple)
|
||||
return testutil.WriteActionFixtureAs(t, tmpDir, "action.txt", testutil.TestFixtureJavaScriptSimple)
|
||||
},
|
||||
expectError: true,
|
||||
},
|
||||
@@ -91,26 +89,26 @@ func TestIsCommitSHA(t *testing.T) {
|
||||
}{
|
||||
{
|
||||
name: "full commit SHA",
|
||||
version: "8f4b7f84bd579b95d7f0b90f8d8b6e5d9b8a7f6e",
|
||||
version: testutil.TestSHAForTesting,
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "short commit SHA",
|
||||
name: testutil.TestCaseNameShortCommitSHA,
|
||||
version: "8f4b7f8",
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "semantic version",
|
||||
version: "v1.2.3",
|
||||
name: testutil.TestCaseNameSemanticVersion,
|
||||
version: testutil.TestVersionSemantic,
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "branch name",
|
||||
version: "main",
|
||||
name: testutil.TestCaseNameBranchName,
|
||||
version: testutil.TestBranchMain,
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "empty string",
|
||||
name: testutil.TestCaseNameEmpty,
|
||||
version: "",
|
||||
expected: false,
|
||||
},
|
||||
@@ -141,12 +139,12 @@ func TestIsSemanticVersion(t *testing.T) {
|
||||
}{
|
||||
{
|
||||
name: "semantic version with v prefix",
|
||||
version: "v1.2.3",
|
||||
version: testutil.TestVersionSemantic,
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "semantic version without v prefix",
|
||||
version: "1.2.3",
|
||||
version: testutil.TestVersionPlain,
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
@@ -160,22 +158,22 @@ func TestIsSemanticVersion(t *testing.T) {
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "major version only",
|
||||
name: testutil.TestCaseNameMajorVersionOnly,
|
||||
version: "v1",
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "commit SHA",
|
||||
version: "8f4b7f84bd579b95d7f0b90f8d8b6e5d9b8a7f6e",
|
||||
name: testutil.TestCaseNameCommitSHA,
|
||||
version: testutil.TestSHAForTesting,
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "branch name",
|
||||
version: "main",
|
||||
name: testutil.TestCaseNameBranchName,
|
||||
version: testutil.TestBranchMain,
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "empty string",
|
||||
name: testutil.TestCaseNameEmpty,
|
||||
version: "",
|
||||
expected: false,
|
||||
},
|
||||
@@ -201,16 +199,16 @@ func TestIsVersionPinned(t *testing.T) {
|
||||
}{
|
||||
{
|
||||
name: "full semantic version",
|
||||
version: "v1.2.3",
|
||||
version: testutil.TestVersionSemantic,
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "full commit SHA",
|
||||
version: "8f4b7f84bd579b95d7f0b90f8d8b6e5d9b8a7f6e",
|
||||
version: testutil.TestSHAForTesting,
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "major version only",
|
||||
name: testutil.TestCaseNameMajorVersionOnly,
|
||||
version: "v1",
|
||||
expected: false,
|
||||
},
|
||||
@@ -220,17 +218,17 @@ func TestIsVersionPinned(t *testing.T) {
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "branch name",
|
||||
version: "main",
|
||||
name: testutil.TestCaseNameBranchName,
|
||||
version: testutil.TestBranchMain,
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "short commit SHA",
|
||||
name: testutil.TestCaseNameShortCommitSHA,
|
||||
version: "8f4b7f8",
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "empty string",
|
||||
name: testutil.TestCaseNameEmpty,
|
||||
version: "",
|
||||
expected: false,
|
||||
},
|
||||
@@ -258,28 +256,27 @@ func TestValidateGitBranch(t *testing.T) {
|
||||
name: "valid git repository with main branch",
|
||||
setupFunc: func(_ *testing.T, tmpDir string) (string, string) {
|
||||
// Create a simple git repository
|
||||
gitDir := filepath.Join(tmpDir, ".git")
|
||||
_ = os.MkdirAll(gitDir, 0750) // #nosec G301 -- test directory permissions
|
||||
gitDir := testutil.SetupGitDirectory(t, tmpDir)
|
||||
|
||||
// Create a basic git config
|
||||
configContent := `[core]
|
||||
repositoryformatversion = 0
|
||||
filemode = true
|
||||
bare = false
|
||||
[branch "main"]
|
||||
[branch testutil.TestBranchMain]
|
||||
remote = origin
|
||||
merge = refs/heads/main
|
||||
`
|
||||
testutil.WriteTestFile(t, filepath.Join(gitDir, "config"), configContent)
|
||||
|
||||
return tmpDir, "main"
|
||||
return tmpDir, testutil.TestBranchMain
|
||||
},
|
||||
expected: true, // This may vary based on actual git repo state
|
||||
},
|
||||
{
|
||||
name: "non-git directory",
|
||||
setupFunc: func(_ *testing.T, tmpDir string) (string, string) {
|
||||
return tmpDir, "main"
|
||||
return tmpDir, testutil.TestBranchMain
|
||||
},
|
||||
expected: false,
|
||||
},
|
||||
@@ -320,8 +317,7 @@ func TestIsGitRepository(t *testing.T) {
|
||||
{
|
||||
name: "directory with .git folder",
|
||||
setupFunc: func(_ *testing.T, tmpDir string) string {
|
||||
gitDir := filepath.Join(tmpDir, ".git")
|
||||
_ = os.MkdirAll(gitDir, 0750) // #nosec G301 -- test directory permissions
|
||||
_ = testutil.SetupGitDirectory(t, tmpDir)
|
||||
|
||||
return tmpDir
|
||||
},
|
||||
@@ -378,28 +374,28 @@ func TestCleanVersionString(t *testing.T) {
|
||||
}{
|
||||
{
|
||||
name: "version with v prefix",
|
||||
input: "v1.2.3",
|
||||
expected: "1.2.3",
|
||||
input: testutil.TestVersionSemantic,
|
||||
expected: testutil.TestVersionPlain,
|
||||
},
|
||||
{
|
||||
name: "version without v prefix",
|
||||
input: "1.2.3",
|
||||
expected: "1.2.3",
|
||||
input: testutil.TestVersionPlain,
|
||||
expected: testutil.TestVersionPlain,
|
||||
},
|
||||
{
|
||||
name: "version with leading/trailing spaces",
|
||||
input: " v1.2.3 ",
|
||||
expected: "1.2.3",
|
||||
expected: testutil.TestVersionPlain,
|
||||
},
|
||||
{
|
||||
name: "empty string",
|
||||
name: testutil.TestCaseNameEmpty,
|
||||
input: "",
|
||||
expected: "",
|
||||
},
|
||||
{
|
||||
name: "commit SHA",
|
||||
input: "8f4b7f84bd579b95d7f0b90f8d8b6e5d9b8a7f6e",
|
||||
expected: "8f4b7f84bd579b95d7f0b90f8d8b6e5d9b8a7f6e",
|
||||
name: testutil.TestCaseNameCommitSHA,
|
||||
input: testutil.TestSHAForTesting,
|
||||
expected: testutil.TestSHAForTesting,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -435,7 +431,7 @@ func TestParseGitHubURL(t *testing.T) {
|
||||
expectedRepo: "repo",
|
||||
},
|
||||
{
|
||||
name: "SSH GitHub URL",
|
||||
name: testutil.TestCaseNameSSHGitHub,
|
||||
url: "git@github.com:owner/repo.git",
|
||||
expectedOrg: "owner",
|
||||
expectedRepo: "repo",
|
||||
@@ -475,21 +471,21 @@ func TestSanitizeActionName(t *testing.T) {
|
||||
}{
|
||||
{
|
||||
name: "normal action name",
|
||||
input: "My Action",
|
||||
expected: "My Action",
|
||||
input: testutil.TestMyAction,
|
||||
expected: testutil.TestMyAction,
|
||||
},
|
||||
{
|
||||
name: "action name with special characters",
|
||||
input: "My Action! @#$%",
|
||||
expected: "My Action ",
|
||||
input: testutil.TestMyAction + "! @#$%",
|
||||
expected: testutil.TestMyAction + " ",
|
||||
},
|
||||
{
|
||||
name: "action name with newlines",
|
||||
input: "My\nAction",
|
||||
expected: "My Action",
|
||||
expected: testutil.TestMyAction,
|
||||
},
|
||||
{
|
||||
name: "empty string",
|
||||
name: testutil.TestCaseNameEmpty,
|
||||
input: "",
|
||||
expected: "",
|
||||
},
|
||||
@@ -534,7 +530,7 @@ func TestEnsureAbsolutePath(t *testing.T) {
|
||||
isAbsolute: true,
|
||||
},
|
||||
{
|
||||
name: "relative path",
|
||||
name: testutil.TestCaseNameRelativePath,
|
||||
input: "./file",
|
||||
isAbsolute: false,
|
||||
},
|
||||
@@ -544,7 +540,7 @@ func TestEnsureAbsolutePath(t *testing.T) {
|
||||
isAbsolute: false,
|
||||
},
|
||||
{
|
||||
name: "empty path",
|
||||
name: testutil.TestCaseNameEmptyPath,
|
||||
input: "",
|
||||
isAbsolute: false,
|
||||
},
|
||||
|
||||
@@ -43,7 +43,10 @@ func ValidateActionYML(action *ActionYML) ValidationResult {
|
||||
result.MissingFields = append(result.MissingFields, appconstants.FieldRunsUsing)
|
||||
result.Suggestions = append(
|
||||
result.Suggestions,
|
||||
fmt.Sprintf("Invalid runtime '%s'. Valid runtimes: node12, node16, node20, docker, composite", using),
|
||||
fmt.Sprintf(
|
||||
"Invalid runtime '%s'. Valid runtimes: node12, node16, node20, docker, composite",
|
||||
using,
|
||||
),
|
||||
)
|
||||
}
|
||||
} else {
|
||||
|
||||
@@ -64,6 +64,7 @@ func setConfigDefaults(v *viper.Viper, defaults *AppConfig) {
|
||||
v.SetDefault(appconstants.ConfigKeyShowSecurityInfo, defaults.ShowSecurityInfo)
|
||||
v.SetDefault(appconstants.ConfigKeyVerbose, defaults.Verbose)
|
||||
v.SetDefault(appconstants.ConfigKeyQuiet, defaults.Quiet)
|
||||
v.SetDefault(appconstants.ConfigKeyIgnoredDirectories, defaults.IgnoredDirectories)
|
||||
v.SetDefault(appconstants.ConfigKeyDefaultsName, defaults.Defaults.Name)
|
||||
v.SetDefault(appconstants.ConfigKeyDefaultsDescription, defaults.Defaults.Description)
|
||||
v.SetDefault(appconstants.ConfigKeyDefaultsBrandingIcon, defaults.Defaults.Branding.Icon)
|
||||
|
||||
@@ -59,7 +59,7 @@ type DetectedSettings struct {
|
||||
func (d *ProjectDetector) DetectProjectSettings() (*DetectedSettings, error) {
|
||||
settings := &DetectedSettings{
|
||||
SuggestedPermissions: make(map[string]string),
|
||||
SuggestedRunsOn: []string{"ubuntu-latest"},
|
||||
SuggestedRunsOn: []string{appconstants.RunnerUbuntuLatest},
|
||||
}
|
||||
|
||||
// Detect repository information
|
||||
@@ -223,28 +223,71 @@ func (d *ProjectDetector) findActionFiles(dir string, recursive bool) ([]string,
|
||||
}
|
||||
|
||||
// findActionFilesRecursive discovers action files recursively using filepath.Walk.
|
||||
//
|
||||
|
||||
func (d *ProjectDetector) findActionFilesRecursive(dir string) ([]string, error) {
|
||||
// Validate directory path
|
||||
if err := validateDirectoryPath(dir); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var actionFiles []string
|
||||
|
||||
err := filepath.Walk(dir, func(path string, info os.FileInfo, err error) error {
|
||||
err := filepath.WalkDir(dir, func(path string, entry os.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return filepath.SkipDir // Skip errors by skipping this directory
|
||||
return filepath.SkipDir
|
||||
}
|
||||
|
||||
if info.IsDir() {
|
||||
return d.handleDirectory(info)
|
||||
}
|
||||
|
||||
if d.isActionFile(info.Name()) {
|
||||
actionFiles = append(actionFiles, path)
|
||||
}
|
||||
|
||||
return nil
|
||||
return d.processWalkDirEntry(path, entry, &actionFiles)
|
||||
})
|
||||
|
||||
return actionFiles, err
|
||||
}
|
||||
|
||||
// validateDirectoryPath checks for path traversal attempts.
|
||||
func validateDirectoryPath(dir string) error {
|
||||
cleanDir := filepath.Clean(dir)
|
||||
|
||||
// Check for ".." as a path component, not substring
|
||||
for _, component := range strings.Split(filepath.ToSlash(cleanDir), "/") {
|
||||
if component == ".." {
|
||||
return fmt.Errorf("invalid directory path: traversal detected in %q", dir)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// processWalkDirEntry processes a single entry during directory walking.
|
||||
func (d *ProjectDetector) processWalkDirEntry(path string, entry os.DirEntry, actionFiles *[]string) error {
|
||||
// Check for symlinks - skip them
|
||||
if entry.Type()&os.ModeSymlink != 0 {
|
||||
return nil // Skip all symlinks
|
||||
}
|
||||
|
||||
// Handle directories
|
||||
if entry.IsDir() {
|
||||
return d.handleDirectoryEntry(entry)
|
||||
}
|
||||
|
||||
// Check if it's an action file
|
||||
if d.isActionFile(entry.Name()) {
|
||||
*actionFiles = append(*actionFiles, path)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// handleDirectoryEntry decides whether to skip a directory during walk.
|
||||
func (d *ProjectDetector) handleDirectoryEntry(entry os.DirEntry) error {
|
||||
info, err := entry.Info()
|
||||
if err != nil {
|
||||
return filepath.SkipDir
|
||||
}
|
||||
|
||||
return d.handleDirectory(info)
|
||||
}
|
||||
|
||||
// handleDirectory decides whether to skip a directory during recursive search.
|
||||
func (d *ProjectDetector) handleDirectory(info os.FileInfo) error {
|
||||
name := info.Name()
|
||||
@@ -257,16 +300,7 @@ func (d *ProjectDetector) handleDirectory(info os.FileInfo) error {
|
||||
|
||||
// findActionFilesInDirectory finds action files only in the specified directory.
|
||||
func (d *ProjectDetector) findActionFilesInDirectory(dir string) ([]string, error) {
|
||||
var actionFiles []string
|
||||
|
||||
for _, filename := range []string{appconstants.ActionFileNameYML, appconstants.ActionFileNameYAML} {
|
||||
actionPath := filepath.Join(dir, filename)
|
||||
if _, err := os.Stat(actionPath); err == nil {
|
||||
actionFiles = append(actionFiles, actionPath)
|
||||
}
|
||||
}
|
||||
|
||||
return actionFiles, nil
|
||||
return internal.DiscoverActionFilesNonRecursive(dir), nil
|
||||
}
|
||||
|
||||
// isActionFile checks if a filename is an action file.
|
||||
@@ -454,15 +488,19 @@ func (d *ProjectDetector) suggestTheme(settings *DetectedSettings) {
|
||||
|
||||
// suggestRunsOn suggests appropriate runners based on language/framework.
|
||||
func (d *ProjectDetector) suggestRunsOn(settings *DetectedSettings) {
|
||||
if len(settings.SuggestedRunsOn) != 1 || settings.SuggestedRunsOn[0] != "ubuntu-latest" {
|
||||
if len(settings.SuggestedRunsOn) != 1 || settings.SuggestedRunsOn[0] != appconstants.RunnerUbuntuLatest {
|
||||
return
|
||||
}
|
||||
|
||||
switch settings.Language {
|
||||
case appconstants.LangJavaScriptTypeScript:
|
||||
settings.SuggestedRunsOn = []string{"ubuntu-latest", "windows-latest", "macos-latest"}
|
||||
settings.SuggestedRunsOn = []string{
|
||||
appconstants.RunnerUbuntuLatest,
|
||||
appconstants.RunnerWindowsLatest,
|
||||
appconstants.RunnerMacosLatest,
|
||||
}
|
||||
case appconstants.LangGo, appconstants.LangPython:
|
||||
settings.SuggestedRunsOn = []string{"ubuntu-latest"}
|
||||
settings.SuggestedRunsOn = []string{appconstants.RunnerUbuntuLatest}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,43 +5,38 @@ import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/ivuorinen/gh-action-readme/appconstants"
|
||||
"github.com/ivuorinen/gh-action-readme/internal"
|
||||
"github.com/ivuorinen/gh-action-readme/testutil"
|
||||
)
|
||||
|
||||
func TestProjectDetector_analyzeProjectFiles(t *testing.T) {
|
||||
func TestProjectDetectorAnalyzeProjectFiles(t *testing.T) {
|
||||
t.Parallel()
|
||||
// Create temporary directory for testing
|
||||
tempDir := t.TempDir()
|
||||
|
||||
// Create test files (go.mod should be processed last to be the final language)
|
||||
testFiles := map[string]string{
|
||||
"Dockerfile": "FROM alpine",
|
||||
"action.yml": "name: Test Action",
|
||||
"next.config.js": "module.exports = {}",
|
||||
"package.json": `{"name": "test", "version": "1.0.0"}`,
|
||||
"go.mod": "module test", // This should be detected last
|
||||
"Dockerfile": "FROM alpine",
|
||||
appconstants.ActionFileNameYML: "name: Test Action",
|
||||
"next.config.js": "module.exports = {}",
|
||||
appconstants.PackageJSON: `{"name": "test", "version": "1.0.0"}`,
|
||||
"go.mod": "module test", // This should be detected last
|
||||
}
|
||||
|
||||
for filename, content := range testFiles {
|
||||
filePath := filepath.Join(tempDir, filename)
|
||||
if err := os.WriteFile(filePath, []byte(content), 0600); err != nil { // #nosec G306 -- test file permissions
|
||||
t.Fatalf("Failed to create test file %s: %v", filename, err)
|
||||
}
|
||||
testutil.WriteFileInDir(t, tempDir, filename, content)
|
||||
}
|
||||
|
||||
// Create detector with temp directory
|
||||
output := internal.NewColoredOutput(true)
|
||||
detector := &ProjectDetector{
|
||||
output: output,
|
||||
currentDir: tempDir,
|
||||
}
|
||||
detector := NewTestDetector(t, tempDir)
|
||||
|
||||
characteristics := detector.analyzeProjectFiles()
|
||||
|
||||
// Test that a language is detected (either Go or JavaScript/TypeScript is valid)
|
||||
// Test that a language is detected (either Go or testutil.TestLangJavaScriptTypeScript is valid)
|
||||
language := characteristics["language"]
|
||||
if language != "Go" && language != "JavaScript/TypeScript" {
|
||||
t.Errorf("Expected language 'Go' or 'JavaScript/TypeScript', got '%s'", language)
|
||||
if language != "Go" && language != testutil.TestLangJavaScriptTypeScript {
|
||||
t.Errorf("Expected language 'Go' or '%s', got '%s'", testutil.TestLangJavaScriptTypeScript, language)
|
||||
}
|
||||
|
||||
// Test that appropriate type is detected
|
||||
@@ -64,27 +59,16 @@ func TestProjectDetector_analyzeProjectFiles(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestProjectDetector_detectVersionFromPackageJSON(t *testing.T) {
|
||||
func TestProjectDetectorDetectVersionFromPackageJSON(t *testing.T) {
|
||||
t.Parallel()
|
||||
tempDir := t.TempDir()
|
||||
|
||||
// Create package.json with version
|
||||
packageJSON := `{
|
||||
"name": "test-package",
|
||||
"version": "2.1.0",
|
||||
"description": "Test package"
|
||||
}`
|
||||
packageJSON := testutil.MustReadFixture(testutil.TestJSONPackageFull)
|
||||
|
||||
packagePath := filepath.Join(tempDir, "package.json")
|
||||
if err := os.WriteFile(packagePath, []byte(packageJSON), 0600); err != nil { // #nosec G306 -- test file permissions
|
||||
t.Fatalf("Failed to create package.json: %v", err)
|
||||
}
|
||||
testutil.WriteFileInDir(t, tempDir, appconstants.PackageJSON, packageJSON)
|
||||
|
||||
output := internal.NewColoredOutput(true)
|
||||
detector := &ProjectDetector{
|
||||
output: output,
|
||||
currentDir: tempDir,
|
||||
}
|
||||
detector := NewTestDetector(t, tempDir)
|
||||
|
||||
version := detector.detectVersionFromPackageJSON()
|
||||
if version != "2.1.0" {
|
||||
@@ -92,22 +76,15 @@ func TestProjectDetector_detectVersionFromPackageJSON(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestProjectDetector_detectVersionFromFiles(t *testing.T) {
|
||||
func TestProjectDetectorDetectVersionFromFiles(t *testing.T) {
|
||||
t.Parallel()
|
||||
tempDir := t.TempDir()
|
||||
|
||||
// Create VERSION file
|
||||
versionContent := "3.2.1\n"
|
||||
versionPath := filepath.Join(tempDir, "VERSION")
|
||||
if err := os.WriteFile(versionPath, []byte(versionContent), 0600); err != nil { // #nosec G306 -- test file permissions
|
||||
t.Fatalf("Failed to create VERSION file: %v", err)
|
||||
}
|
||||
testutil.WriteFileInDir(t, tempDir, "VERSION", versionContent)
|
||||
|
||||
output := internal.NewColoredOutput(true)
|
||||
detector := &ProjectDetector{
|
||||
output: output,
|
||||
currentDir: tempDir,
|
||||
}
|
||||
detector := NewTestDetector(t, tempDir)
|
||||
|
||||
version := detector.detectVersionFromFiles()
|
||||
if version != "3.2.1" {
|
||||
@@ -115,40 +92,22 @@ func TestProjectDetector_detectVersionFromFiles(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestProjectDetector_findActionFiles(t *testing.T) {
|
||||
func TestProjectDetectorFindActionFiles(t *testing.T) {
|
||||
t.Parallel()
|
||||
tempDir := t.TempDir()
|
||||
|
||||
// Create action files
|
||||
actionYML := filepath.Join(tempDir, "action.yml")
|
||||
if err := os.WriteFile(
|
||||
actionYML,
|
||||
[]byte("name: Test Action"),
|
||||
0600, // #nosec G306 -- test file permissions
|
||||
); err != nil {
|
||||
t.Fatalf("Failed to create action.yml: %v", err)
|
||||
}
|
||||
actionYML := filepath.Join(tempDir, appconstants.ActionFileNameYML)
|
||||
testutil.WriteTestFile(t, actionYML, "name: Test Action")
|
||||
|
||||
// Create subdirectory with another action file
|
||||
subDir := filepath.Join(tempDir, "subaction")
|
||||
if err := os.MkdirAll(subDir, 0750); err != nil { // #nosec G301 -- test directory permissions
|
||||
t.Fatalf("Failed to create subdirectory: %v", err)
|
||||
}
|
||||
testutil.CreateTestDir(t, subDir)
|
||||
|
||||
subActionYAML := filepath.Join(subDir, "action.yaml")
|
||||
if err := os.WriteFile(
|
||||
subActionYAML,
|
||||
[]byte("name: Sub Action"),
|
||||
0600, // #nosec G306 -- test file permissions
|
||||
); err != nil {
|
||||
t.Fatalf("Failed to create sub action.yaml: %v", err)
|
||||
}
|
||||
testutil.WriteTestFile(t, subActionYAML, "name: Sub Action")
|
||||
|
||||
output := internal.NewColoredOutput(true)
|
||||
detector := &ProjectDetector{
|
||||
output: output,
|
||||
currentDir: tempDir,
|
||||
}
|
||||
detector := NewTestDetector(t, tempDir)
|
||||
|
||||
// Test non-recursive
|
||||
files, err := detector.findActionFiles(tempDir, false)
|
||||
@@ -171,7 +130,7 @@ func TestProjectDetector_findActionFiles(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestProjectDetector_isActionFile(t *testing.T) {
|
||||
func TestProjectDetectorIsActionFile(t *testing.T) {
|
||||
t.Parallel()
|
||||
output := internal.NewColoredOutput(true)
|
||||
detector := &ProjectDetector{
|
||||
@@ -182,7 +141,7 @@ func TestProjectDetector_isActionFile(t *testing.T) {
|
||||
filename string
|
||||
expected bool
|
||||
}{
|
||||
{"action.yml", true},
|
||||
{appconstants.ActionFileNameYML, true},
|
||||
{"action.yaml", true},
|
||||
{"Action.yml", false},
|
||||
{"action.yml.bak", false},
|
||||
@@ -201,7 +160,7 @@ func TestProjectDetector_isActionFile(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestProjectDetector_suggestConfiguration(t *testing.T) {
|
||||
func TestProjectDetectorSuggestConfiguration(t *testing.T) {
|
||||
t.Parallel()
|
||||
output := internal.NewColoredOutput(true)
|
||||
detector := &ProjectDetector{
|
||||
@@ -214,7 +173,7 @@ func TestProjectDetector_suggestConfiguration(t *testing.T) {
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "composite action",
|
||||
name: testutil.TestCaseNameCompositeAction,
|
||||
settings: &DetectedSettings{
|
||||
HasCompositeAction: true,
|
||||
},
|
||||
@@ -258,3 +217,610 @@ func TestProjectDetector_suggestConfiguration(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestProjectDetectorSuggestRunsOn tests the runner suggestion logic.
|
||||
func TestProjectDetectorSuggestRunsOn(t *testing.T) {
|
||||
t.Parallel()
|
||||
output := internal.NewColoredOutput(true)
|
||||
detector := &ProjectDetector{
|
||||
output: output,
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
settings *DetectedSettings
|
||||
expected []string
|
||||
}{
|
||||
{
|
||||
name: "javascript/typescript project",
|
||||
settings: &DetectedSettings{
|
||||
Language: testutil.TestLangJavaScriptTypeScript,
|
||||
SuggestedRunsOn: []string{testutil.RunnerUbuntuLatest},
|
||||
},
|
||||
expected: []string{
|
||||
testutil.RunnerUbuntuLatest,
|
||||
testutil.RunnerWindowsLatest,
|
||||
testutil.RunnerMacosLatest,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "go project",
|
||||
settings: &DetectedSettings{
|
||||
Language: "Go",
|
||||
SuggestedRunsOn: []string{testutil.RunnerUbuntuLatest},
|
||||
},
|
||||
expected: []string{testutil.RunnerUbuntuLatest},
|
||||
},
|
||||
{
|
||||
name: "python project",
|
||||
settings: &DetectedSettings{
|
||||
Language: "Python",
|
||||
SuggestedRunsOn: []string{testutil.RunnerUbuntuLatest},
|
||||
},
|
||||
expected: []string{testutil.RunnerUbuntuLatest},
|
||||
},
|
||||
{
|
||||
name: "already has multiple runners",
|
||||
settings: &DetectedSettings{
|
||||
Language: testutil.TestLangJavaScriptTypeScript,
|
||||
SuggestedRunsOn: []string{testutil.RunnerUbuntuLatest, "custom-runner"},
|
||||
},
|
||||
expected: []string{testutil.RunnerUbuntuLatest, "custom-runner"},
|
||||
},
|
||||
{
|
||||
name: "unknown language",
|
||||
settings: &DetectedSettings{
|
||||
Language: "Rust",
|
||||
SuggestedRunsOn: []string{testutil.RunnerUbuntuLatest},
|
||||
},
|
||||
expected: []string{testutil.RunnerUbuntuLatest},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
detector.suggestRunsOn(tt.settings)
|
||||
|
||||
if len(tt.settings.SuggestedRunsOn) != len(tt.expected) {
|
||||
t.Errorf("Expected %d runners, got %d", len(tt.expected), len(tt.settings.SuggestedRunsOn))
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
for i, expectedRunner := range tt.expected {
|
||||
if tt.settings.SuggestedRunsOn[i] != expectedRunner {
|
||||
t.Errorf("Expected runner at index %d to be %s, got %s",
|
||||
i, expectedRunner, tt.settings.SuggestedRunsOn[i])
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// assertPermissionsMatch is a helper to validate permissions in tests.
|
||||
func assertPermissionsMatch(t *testing.T, expected, actual map[string]string) {
|
||||
t.Helper()
|
||||
|
||||
if expected == nil && actual != nil {
|
||||
t.Errorf("Expected nil permissions, got %v", actual)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if expected != nil && actual == nil {
|
||||
t.Errorf("Expected permissions %v, got nil", expected)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if expected == nil {
|
||||
return
|
||||
}
|
||||
|
||||
if len(actual) != len(expected) {
|
||||
t.Errorf("Expected %d permissions, got %d", len(expected), len(actual))
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
for key, expectedValue := range expected {
|
||||
if actualValue, ok := actual[key]; !ok {
|
||||
t.Errorf("Expected permission %s not found", key)
|
||||
} else if actualValue != expectedValue {
|
||||
t.Errorf("Expected permission %s=%s, got %s=%s",
|
||||
key, expectedValue, key, actualValue)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestProjectDetectorSuggestPermissions tests the permissions suggestion logic.
|
||||
func TestProjectDetectorSuggestPermissions(t *testing.T) {
|
||||
t.Parallel()
|
||||
output := internal.NewColoredOutput(true)
|
||||
detector := &ProjectDetector{
|
||||
output: output,
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
settings *DetectedSettings
|
||||
expected map[string]string
|
||||
}{
|
||||
{
|
||||
name: "github action without permissions",
|
||||
settings: &DetectedSettings{
|
||||
IsGitHubAction: true,
|
||||
SuggestedPermissions: nil,
|
||||
},
|
||||
expected: map[string]string{
|
||||
"contents": "read",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "github action with existing permissions",
|
||||
settings: &DetectedSettings{
|
||||
IsGitHubAction: true,
|
||||
SuggestedPermissions: map[string]string{
|
||||
"contents": "write",
|
||||
"issues": "read",
|
||||
},
|
||||
},
|
||||
expected: map[string]string{
|
||||
"contents": "write",
|
||||
"issues": "read",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "not a github action",
|
||||
settings: &DetectedSettings{
|
||||
IsGitHubAction: false,
|
||||
SuggestedPermissions: nil,
|
||||
},
|
||||
expected: nil,
|
||||
},
|
||||
{
|
||||
name: "github action with empty permissions map",
|
||||
settings: &DetectedSettings{
|
||||
IsGitHubAction: true,
|
||||
SuggestedPermissions: map[string]string{},
|
||||
},
|
||||
expected: map[string]string{
|
||||
"contents": "read",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
detector.suggestPermissions(tt.settings)
|
||||
assertPermissionsMatch(t, tt.expected, tt.settings.SuggestedPermissions)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestNewProjectDetector tests creating a new project detector.
|
||||
func TestNewProjectDetector(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
output := internal.NewColoredOutput(true)
|
||||
detector, err := NewProjectDetector(output)
|
||||
if err != nil {
|
||||
t.Fatalf("NewProjectDetector() error = %v", err)
|
||||
}
|
||||
|
||||
if detector == nil {
|
||||
t.Fatal("NewProjectDetector() returned nil")
|
||||
}
|
||||
|
||||
if detector.output == nil {
|
||||
t.Error("detector.output is nil")
|
||||
}
|
||||
|
||||
if detector.currentDir == "" {
|
||||
t.Error("detector.currentDir is empty")
|
||||
}
|
||||
}
|
||||
|
||||
// TestDetectProjectSettingsIntegration tests the main detection logic.
|
||||
func TestDetectProjectSettingsIntegration(t *testing.T) {
|
||||
// Cannot use t.Parallel() because this test uses t.Chdir()
|
||||
|
||||
// Create a temporary directory with test files
|
||||
tempDir := t.TempDir()
|
||||
|
||||
// Create action.yml
|
||||
testutil.WriteActionFixture(t, tempDir, testutil.TestFixtureCompositeWithShellStep)
|
||||
|
||||
// Change to temp directory (cleanup automatic via t.Chdir)
|
||||
t.Chdir(tempDir)
|
||||
|
||||
output := internal.NewColoredOutput(true)
|
||||
detector, err := NewProjectDetector(output)
|
||||
if err != nil {
|
||||
t.Fatalf("NewProjectDetector() error = %v", err)
|
||||
}
|
||||
|
||||
settings, err := detector.DetectProjectSettings()
|
||||
if err != nil {
|
||||
t.Fatalf("DetectProjectSettings() error = %v", err)
|
||||
}
|
||||
|
||||
if settings == nil {
|
||||
t.Fatal("DetectProjectSettings() returned nil")
|
||||
}
|
||||
|
||||
// Verify action file was detected
|
||||
if !settings.IsGitHubAction {
|
||||
t.Error("Expected IsGitHubAction to be true")
|
||||
}
|
||||
|
||||
if len(settings.ActionFiles) == 0 {
|
||||
t.Error("Expected at least one action file to be detected")
|
||||
}
|
||||
|
||||
// Verify default values are set
|
||||
if len(settings.SuggestedRunsOn) == 0 {
|
||||
t.Error("Expected SuggestedRunsOn to have default values")
|
||||
}
|
||||
|
||||
if settings.SuggestedPermissions == nil {
|
||||
t.Error("Expected SuggestedPermissions to be initialized")
|
||||
}
|
||||
}
|
||||
|
||||
// TestDetectRepositoryInfo tests repository info detection.
|
||||
func TestDetectRepositoryInfo(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
repoRoot string
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: testutil.TestCaseNameNoGitRepository,
|
||||
repoRoot: "",
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
output := internal.NewColoredOutput(true)
|
||||
detector := &ProjectDetector{
|
||||
output: output,
|
||||
repoRoot: tt.repoRoot,
|
||||
}
|
||||
|
||||
settings := &DetectedSettings{
|
||||
SuggestedPermissions: make(map[string]string),
|
||||
}
|
||||
|
||||
err := detector.detectRepositoryInfo(settings)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("detectRepositoryInfo() error = %v, wantErr %v", err, tt.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestDetectActionFiles tests action file detection.
|
||||
//
|
||||
// validateDetectActionFilesResult validates the results of detectActionFiles call.
|
||||
func validateDetectActionFilesResult(
|
||||
t *testing.T,
|
||||
settings *DetectedSettings,
|
||||
err error,
|
||||
wantActionCount int,
|
||||
wantErr bool,
|
||||
) {
|
||||
t.Helper()
|
||||
|
||||
if (err != nil) != wantErr {
|
||||
t.Errorf("detectActionFiles() error = %v, wantErr %v", err, wantErr)
|
||||
}
|
||||
|
||||
if len(settings.ActionFiles) != wantActionCount {
|
||||
t.Errorf("Expected %d action files, got %d", wantActionCount, len(settings.ActionFiles))
|
||||
}
|
||||
|
||||
if wantActionCount > 0 && !settings.IsGitHubAction {
|
||||
t.Error("Expected IsGitHubAction to be true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectActionFiles(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
setupFunc func(t *testing.T, dir string)
|
||||
wantActionCount int
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "detects action file",
|
||||
setupFunc: func(t *testing.T, dir string) {
|
||||
t.Helper()
|
||||
content := "name: Test Action\ndescription: Test"
|
||||
testutil.WriteFileInDir(t, dir, appconstants.ActionFileNameYML, content)
|
||||
},
|
||||
wantActionCount: 1,
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: testutil.TestCaseNameNoActionFiles,
|
||||
setupFunc: func(t *testing.T, _ string) {
|
||||
t.Helper()
|
||||
// Don't create any files
|
||||
},
|
||||
wantActionCount: 0,
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "skips symlink to sensitive file",
|
||||
setupFunc: func(t *testing.T, dir string) {
|
||||
t.Helper()
|
||||
// Create symlink: action.yml -> /etc/passwd
|
||||
symlinkPath := filepath.Join(dir, appconstants.ActionFileNameYML)
|
||||
err := os.Symlink("/etc/passwd", symlinkPath)
|
||||
if err != nil {
|
||||
t.Skip("symlink creation not supported on this platform")
|
||||
}
|
||||
},
|
||||
wantActionCount: 0, // Should skip symlinks for security
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "handles directory with .. components safely",
|
||||
setupFunc: func(t *testing.T, dir string) {
|
||||
t.Helper()
|
||||
// Create subdirectory with action.yml
|
||||
content := "name: Test\ndescription: Test"
|
||||
testutil.CreateNestedAction(t, dir, "subdir", content)
|
||||
},
|
||||
wantActionCount: 1, // Should find the file safely
|
||||
wantErr: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tempDir := t.TempDir()
|
||||
if tt.setupFunc != nil {
|
||||
tt.setupFunc(t, tempDir)
|
||||
}
|
||||
|
||||
output := internal.NewColoredOutput(true)
|
||||
detector := &ProjectDetector{
|
||||
output: output,
|
||||
currentDir: tempDir,
|
||||
}
|
||||
|
||||
settings := &DetectedSettings{
|
||||
SuggestedPermissions: make(map[string]string),
|
||||
}
|
||||
|
||||
err := detector.detectActionFiles(settings)
|
||||
|
||||
validateDetectActionFilesResult(t, settings, err, tt.wantActionCount, tt.wantErr)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestDetectProjectCharacteristics tests project characteristics detection.
|
||||
func TestDetectProjectCharacteristics(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
setupFunc func(t *testing.T, dir string)
|
||||
wantDockerfile bool
|
||||
}{
|
||||
{
|
||||
name: "detects Dockerfile",
|
||||
setupFunc: func(t *testing.T, dir string) {
|
||||
t.Helper()
|
||||
content := "FROM alpine:latest"
|
||||
testutil.WriteFileInDir(t, dir, "Dockerfile", content)
|
||||
},
|
||||
wantDockerfile: true,
|
||||
},
|
||||
{
|
||||
name: "no Dockerfile",
|
||||
setupFunc: func(t *testing.T, _ string) {
|
||||
t.Helper()
|
||||
// Don't create Dockerfile
|
||||
},
|
||||
wantDockerfile: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tempDir := t.TempDir()
|
||||
if tt.setupFunc != nil {
|
||||
tt.setupFunc(t, tempDir)
|
||||
}
|
||||
|
||||
output := internal.NewColoredOutput(true)
|
||||
detector := &ProjectDetector{
|
||||
output: output,
|
||||
currentDir: tempDir,
|
||||
}
|
||||
|
||||
settings := &DetectedSettings{
|
||||
SuggestedPermissions: make(map[string]string),
|
||||
}
|
||||
|
||||
detector.detectProjectCharacteristics(settings)
|
||||
|
||||
if settings.HasDockerfile != tt.wantDockerfile {
|
||||
t.Errorf("HasDockerfile = %v, want %v", settings.HasDockerfile, tt.wantDockerfile)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestDetectVersion tests version detection from various sources.
|
||||
func TestDetectVersion(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
setupFunc func(t *testing.T, dir string)
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "detects version from package.json",
|
||||
setupFunc: func(t *testing.T, dir string) {
|
||||
t.Helper()
|
||||
content := testutil.MustReadFixture(testutil.TestJSONPackageVersionOnly)
|
||||
testutil.WriteFileInDir(t, dir, appconstants.PackageJSON, content)
|
||||
},
|
||||
want: "1.2.3",
|
||||
},
|
||||
{
|
||||
name: "detects version from VERSION file",
|
||||
setupFunc: func(t *testing.T, dir string) {
|
||||
t.Helper()
|
||||
content := "2.0.0\n"
|
||||
testutil.WriteFileInDir(t, dir, "VERSION", content)
|
||||
},
|
||||
want: "2.0.0",
|
||||
},
|
||||
{
|
||||
name: "no version found",
|
||||
setupFunc: func(t *testing.T, _ string) {
|
||||
t.Helper()
|
||||
// Don't create version files
|
||||
},
|
||||
want: "",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tempDir := t.TempDir()
|
||||
if tt.setupFunc != nil {
|
||||
tt.setupFunc(t, tempDir)
|
||||
}
|
||||
|
||||
output := internal.NewColoredOutput(true)
|
||||
detector := &ProjectDetector{
|
||||
output: output,
|
||||
currentDir: tempDir,
|
||||
}
|
||||
|
||||
version := detector.detectVersion()
|
||||
if version != tt.want {
|
||||
t.Errorf("detectVersion() = %q, want %q", version, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestDetectVersionFromGitTags tests git tag version detection.
|
||||
func TestDetectVersionFromGitTags(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
repoRoot string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: testutil.TestCaseNameNoGitRepository,
|
||||
repoRoot: "",
|
||||
want: "",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
output := internal.NewColoredOutput(true)
|
||||
detector := &ProjectDetector{
|
||||
output: output,
|
||||
repoRoot: tt.repoRoot,
|
||||
}
|
||||
|
||||
version := detector.detectVersionFromGitTags()
|
||||
if version != tt.want {
|
||||
t.Errorf("detectVersionFromGitTags() = %q, want %q", version, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestAnalyzeActionFile tests action file analysis.
|
||||
func TestAnalyzeActionFile(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
content string
|
||||
wantErr bool
|
||||
checkFunc func(t *testing.T, settings *DetectedSettings)
|
||||
}{
|
||||
{
|
||||
name: "analyzes composite action",
|
||||
content: testutil.MustReadFixture(testutil.TestFixtureCompositeWithShellStep),
|
||||
wantErr: false,
|
||||
checkFunc: func(t *testing.T, settings *DetectedSettings) {
|
||||
t.Helper()
|
||||
if !settings.HasCompositeAction {
|
||||
t.Error("Expected HasCompositeAction to be true")
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "handles invalid YAML",
|
||||
content: "invalid: yaml: content:",
|
||||
wantErr: true,
|
||||
checkFunc: func(t *testing.T, _ *DetectedSettings) {
|
||||
t.Helper()
|
||||
// No specific checks for error case
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tempDir := t.TempDir()
|
||||
actionPath := testutil.WriteFileInDir(t, tempDir, appconstants.ActionFileNameYML, tt.content)
|
||||
|
||||
output := internal.NewColoredOutput(true)
|
||||
detector := &ProjectDetector{
|
||||
output: output,
|
||||
currentDir: tempDir,
|
||||
}
|
||||
|
||||
settings := &DetectedSettings{
|
||||
SuggestedPermissions: make(map[string]string),
|
||||
}
|
||||
|
||||
err := detector.analyzeActionFile(actionPath, settings)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("analyzeActionFile() error = %v, wantErr %v", err, tt.wantErr)
|
||||
}
|
||||
|
||||
if tt.checkFunc != nil {
|
||||
tt.checkFunc(t, settings)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
47
internal/wizard/detector_test_helper.go
Normal file
47
internal/wizard/detector_test_helper.go
Normal file
@@ -0,0 +1,47 @@
|
||||
package wizard
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/ivuorinen/gh-action-readme/internal"
|
||||
"github.com/ivuorinen/gh-action-readme/testutil"
|
||||
)
|
||||
|
||||
// NewTestDetector creates a ProjectDetector configured for testing.
|
||||
// Reduces the 3-line detector initialization pattern to a single line.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// detector := NewTestDetector(t, tempDir)
|
||||
func NewTestDetector(t *testing.T, currentDir string) *ProjectDetector {
|
||||
t.Helper()
|
||||
output := internal.NewColoredOutput(true)
|
||||
|
||||
return &ProjectDetector{
|
||||
output: output,
|
||||
currentDir: currentDir,
|
||||
}
|
||||
}
|
||||
|
||||
// SetupDetectorWithFiles creates a detector and writes test files to its directory.
|
||||
// Returns the detector and temp directory path.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// detector, tmpDir := SetupDetectorWithFiles(t, map[string]string{
|
||||
// "action.yml": "name: Test",
|
||||
// "package.json": `{"version": "1.0.0"}`,
|
||||
// })
|
||||
func SetupDetectorWithFiles(
|
||||
t *testing.T,
|
||||
files map[string]string,
|
||||
) (*ProjectDetector, string) {
|
||||
t.Helper()
|
||||
tmpDir := t.TempDir()
|
||||
|
||||
for filename, content := range files {
|
||||
testutil.WriteFileInDir(t, tmpDir, filename, content)
|
||||
}
|
||||
|
||||
return NewTestDetector(t, tmpDir), tmpDir
|
||||
}
|
||||
@@ -267,24 +267,22 @@ func (e *ConfigExporter) writeWorkflowSection(file *os.File, config *internal.Ap
|
||||
|
||||
// writePermissionsSection writes the permissions section.
|
||||
func (e *ConfigExporter) writePermissionsSection(file *os.File, config *internal.AppConfig) {
|
||||
if len(config.Permissions) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
_, _ = fmt.Fprintf(file, "\n[permissions]\n")
|
||||
for key, value := range config.Permissions {
|
||||
_, _ = fmt.Fprintf(file, appconstants.FormatEnvVar, key, value)
|
||||
}
|
||||
e.writeMapSection(file, "[permissions]", config.Permissions)
|
||||
}
|
||||
|
||||
// writeVariablesSection writes the variables section.
|
||||
func (e *ConfigExporter) writeVariablesSection(file *os.File, config *internal.AppConfig) {
|
||||
if len(config.Variables) == 0 {
|
||||
e.writeMapSection(file, "[variables]", config.Variables)
|
||||
}
|
||||
|
||||
// writeMapSection writes a TOML section with key-value pairs from a map.
|
||||
func (e *ConfigExporter) writeMapSection(file *os.File, sectionName string, data map[string]string) {
|
||||
if len(data) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
_, _ = fmt.Fprintf(file, "\n[variables]\n")
|
||||
for key, value := range config.Variables {
|
||||
_, _ = fmt.Fprintf(file, "\n%s\n", sectionName)
|
||||
for key, value := range data {
|
||||
_, _ = fmt.Fprintf(file, appconstants.FormatEnvVar, key, value)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ import (
|
||||
"github.com/ivuorinen/gh-action-readme/testutil"
|
||||
)
|
||||
|
||||
func TestConfigExporter_ExportConfig(t *testing.T) {
|
||||
func TestConfigExporterExportConfig(t *testing.T) {
|
||||
t.Parallel()
|
||||
output := internal.NewColoredOutput(true) // quiet mode for testing
|
||||
exporter := NewConfigExporter(output)
|
||||
@@ -62,11 +62,11 @@ func testYAMLExport(exporter *ConfigExporter, config *internal.AppConfig) func(*
|
||||
return func(t *testing.T) {
|
||||
t.Helper()
|
||||
tempDir := t.TempDir()
|
||||
outputPath := filepath.Join(tempDir, "config.yaml")
|
||||
outputPath := filepath.Join(tempDir, testutil.TestFileConfigYAML)
|
||||
|
||||
err := exporter.ExportConfig(config, FormatYAML, outputPath)
|
||||
if err != nil {
|
||||
t.Fatalf("ExportConfig() error = %v", err)
|
||||
t.Fatalf(testutil.TestMsgExportConfigError, err)
|
||||
}
|
||||
|
||||
testutil.AssertFileExists(t, outputPath)
|
||||
@@ -83,7 +83,7 @@ func testJSONExport(exporter *ConfigExporter, config *internal.AppConfig) func(*
|
||||
|
||||
err := exporter.ExportConfig(config, FormatJSON, outputPath)
|
||||
if err != nil {
|
||||
t.Fatalf("ExportConfig() error = %v", err)
|
||||
t.Fatalf(testutil.TestMsgExportConfigError, err)
|
||||
}
|
||||
|
||||
testutil.AssertFileExists(t, outputPath)
|
||||
@@ -100,7 +100,7 @@ func testTOMLExport(exporter *ConfigExporter, config *internal.AppConfig) func(*
|
||||
|
||||
err := exporter.ExportConfig(config, FormatTOML, outputPath)
|
||||
if err != nil {
|
||||
t.Fatalf("ExportConfig() error = %v", err)
|
||||
t.Fatalf(testutil.TestMsgExportConfigError, err)
|
||||
}
|
||||
|
||||
testutil.AssertFileExists(t, outputPath)
|
||||
@@ -113,7 +113,7 @@ func verifyYAMLContent(t *testing.T, outputPath string, expected *internal.AppCo
|
||||
t.Helper()
|
||||
data, err := os.ReadFile(outputPath) // #nosec G304 -- test output path
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to read output file: %v", err)
|
||||
t.Fatalf(testutil.TestMsgFailedReadOutput, err)
|
||||
}
|
||||
|
||||
var yamlConfig internal.AppConfig
|
||||
@@ -134,7 +134,7 @@ func verifyJSONContent(t *testing.T, outputPath string, expected *internal.AppCo
|
||||
t.Helper()
|
||||
data, err := os.ReadFile(outputPath) // #nosec G304 -- test output path
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to read output file: %v", err)
|
||||
t.Fatalf(testutil.TestMsgFailedReadOutput, err)
|
||||
}
|
||||
|
||||
var jsonConfig internal.AppConfig
|
||||
@@ -155,7 +155,7 @@ func verifyTOMLContent(t *testing.T, outputPath string) {
|
||||
t.Helper()
|
||||
data, err := os.ReadFile(outputPath) // #nosec G304 -- test output path
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to read output file: %v", err)
|
||||
t.Fatalf(testutil.TestMsgFailedReadOutput, err)
|
||||
}
|
||||
|
||||
content := string(data)
|
||||
@@ -167,7 +167,7 @@ func verifyTOMLContent(t *testing.T, outputPath string) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigExporter_sanitizeConfig(t *testing.T) {
|
||||
func TestConfigExporterSanitizeConfig(t *testing.T) {
|
||||
t.Parallel()
|
||||
output := internal.NewColoredOutput(true)
|
||||
exporter := NewConfigExporter(output)
|
||||
@@ -201,7 +201,7 @@ func TestConfigExporter_sanitizeConfig(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigExporter_GetSupportedFormats(t *testing.T) {
|
||||
func TestConfigExporterGetSupportedFormats(t *testing.T) {
|
||||
t.Parallel()
|
||||
output := internal.NewColoredOutput(true)
|
||||
exporter := NewConfigExporter(output)
|
||||
@@ -226,7 +226,7 @@ func TestConfigExporter_GetSupportedFormats(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigExporter_GetDefaultOutputPath(t *testing.T) {
|
||||
func TestConfigExporterGetDefaultOutputPath(t *testing.T) {
|
||||
t.Parallel()
|
||||
output := internal.NewColoredOutput(true)
|
||||
exporter := NewConfigExporter(output)
|
||||
@@ -235,7 +235,7 @@ func TestConfigExporter_GetDefaultOutputPath(t *testing.T) {
|
||||
format ExportFormat
|
||||
expected string
|
||||
}{
|
||||
{FormatYAML, "config.yaml"},
|
||||
{FormatYAML, testutil.TestFileConfigYAML},
|
||||
{FormatJSON, "config.json"},
|
||||
{FormatTOML, "config.toml"},
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"github.com/ivuorinen/gh-action-readme/appconstants"
|
||||
@@ -34,6 +35,22 @@ type ValidationWarning struct {
|
||||
Value string
|
||||
}
|
||||
|
||||
// validPermissionsMap defines valid GitHub Actions permissions and their allowed values.
|
||||
var validPermissionsMap = map[string][]string{
|
||||
"actions": {"read", "write"},
|
||||
"checks": {"read", "write"},
|
||||
"contents": {"read", "write"},
|
||||
"deployments": {"read", "write"},
|
||||
"id-token": {"write"},
|
||||
"issues": {"read", "write"},
|
||||
"discussions": {"read", "write"},
|
||||
"packages": {"read", "write"},
|
||||
"pull-requests": {"read", "write"},
|
||||
"repository-projects": {"read", "write"},
|
||||
"security-events": {"read", "write"},
|
||||
"statuses": {"read", "write"},
|
||||
}
|
||||
|
||||
// ConfigValidator handles configuration validation with immediate feedback.
|
||||
type ConfigValidator struct {
|
||||
output *internal.ColoredOutput
|
||||
@@ -83,11 +100,11 @@ func (v *ConfigValidator) ValidateField(fieldName, value string) *ValidationResu
|
||||
}
|
||||
|
||||
switch fieldName {
|
||||
case "organization":
|
||||
case appconstants.ConfigKeyOrganization:
|
||||
v.validateOrganization(value, result)
|
||||
case "repository":
|
||||
case appconstants.ConfigKeyRepository:
|
||||
v.validateRepository(value, result)
|
||||
case "version":
|
||||
case appconstants.ConfigKeyVersion:
|
||||
v.validateVersion(value, result)
|
||||
case appconstants.ConfigKeyTheme:
|
||||
v.validateTheme(value, result)
|
||||
@@ -139,50 +156,38 @@ func (v *ConfigValidator) DisplayValidationResult(result *ValidationResult) {
|
||||
|
||||
// validateOrganization validates the organization field.
|
||||
func (v *ConfigValidator) validateOrganization(org string, result *ValidationResult) {
|
||||
if org == "" {
|
||||
result.Warnings = append(result.Warnings, ValidationWarning{
|
||||
Field: "organization",
|
||||
Message: "Organization is empty - will use auto-detected value",
|
||||
Value: org,
|
||||
})
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// GitHub username/organization rules
|
||||
if !v.isValidGitHubName(org) {
|
||||
result.Errors = append(result.Errors, ValidationError{
|
||||
Field: "organization",
|
||||
Message: "Invalid organization name format",
|
||||
Value: org,
|
||||
})
|
||||
result.Suggestions = append(result.Suggestions,
|
||||
"Organization names can only contain alphanumeric characters and hyphens")
|
||||
}
|
||||
v.validateFieldWithEmptyCheck(
|
||||
appconstants.ConfigKeyOrganization,
|
||||
org,
|
||||
v.isValidGitHubName,
|
||||
"Organization is empty - will use auto-detected value",
|
||||
"Invalid organization name format",
|
||||
"Organization names can only contain alphanumeric characters and hyphens",
|
||||
result,
|
||||
)
|
||||
}
|
||||
|
||||
// validateRepository validates the repository field.
|
||||
func (v *ConfigValidator) validateRepository(repo string, result *ValidationResult) {
|
||||
if repo == "" {
|
||||
result.Warnings = append(result.Warnings, ValidationWarning{
|
||||
Field: "repository",
|
||||
Message: "Repository is empty - will use auto-detected value",
|
||||
Value: repo,
|
||||
})
|
||||
v.validateFieldWithEmptyCheck(
|
||||
appconstants.ConfigKeyRepository,
|
||||
repo,
|
||||
v.isValidGitHubName,
|
||||
"Repository is empty - will use auto-detected value",
|
||||
"Invalid repository name format",
|
||||
"Repository names can only contain alphanumeric characters, hyphens, and underscores",
|
||||
result,
|
||||
)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// GitHub repository name rules
|
||||
if !v.isValidGitHubName(repo) {
|
||||
result.Errors = append(result.Errors, ValidationError{
|
||||
Field: "repository",
|
||||
Message: "Invalid repository name format",
|
||||
Value: repo,
|
||||
})
|
||||
result.Suggestions = append(result.Suggestions,
|
||||
"Repository names can only contain alphanumeric characters, hyphens, and underscores")
|
||||
}
|
||||
// addWarningWithSuggestion is a helper to add a warning and suggestion together.
|
||||
func addWarningWithSuggestion(result *ValidationResult, field, message, value, suggestion string) {
|
||||
result.Warnings = append(result.Warnings, ValidationWarning{
|
||||
Field: field,
|
||||
Message: message,
|
||||
Value: value,
|
||||
})
|
||||
result.Suggestions = append(result.Suggestions, suggestion)
|
||||
}
|
||||
|
||||
// validateVersion validates the version field.
|
||||
@@ -194,69 +199,39 @@ func (v *ConfigValidator) validateVersion(version string, result *ValidationResu
|
||||
|
||||
// Check if it follows semantic versioning
|
||||
if !v.isValidSemanticVersion(version) {
|
||||
result.Warnings = append(result.Warnings, ValidationWarning{
|
||||
Field: "version",
|
||||
Message: "Version does not follow semantic versioning (x.y.z)",
|
||||
Value: version,
|
||||
})
|
||||
result.Suggestions = append(result.Suggestions,
|
||||
addWarningWithSuggestion(result,
|
||||
appconstants.ConfigKeyVersion,
|
||||
"Version does not follow semantic versioning (x.y.z)",
|
||||
version,
|
||||
"Consider using semantic versioning format (e.g., 1.0.0)")
|
||||
}
|
||||
}
|
||||
|
||||
// validateTheme validates the theme field.
|
||||
func (v *ConfigValidator) validateTheme(theme string, result *ValidationResult) {
|
||||
validThemes := []string{"default", "github", "gitlab", "minimal", "professional"}
|
||||
|
||||
found := false
|
||||
for _, validTheme := range validThemes {
|
||||
if theme == validTheme {
|
||||
found = true
|
||||
|
||||
break
|
||||
}
|
||||
validThemes := []string{
|
||||
appconstants.ThemeDefault,
|
||||
appconstants.ThemeGitHub,
|
||||
appconstants.ThemeGitLab,
|
||||
appconstants.ThemeMinimal,
|
||||
appconstants.ThemeProfessional,
|
||||
}
|
||||
|
||||
if !found {
|
||||
result.Errors = append(result.Errors, ValidationError{
|
||||
Field: "theme",
|
||||
Message: "Invalid theme",
|
||||
Value: theme,
|
||||
})
|
||||
result.Suggestions = append(result.Suggestions,
|
||||
"Valid themes: "+strings.Join(validThemes, ", "))
|
||||
}
|
||||
v.validateFieldInList("theme", theme, validThemes, "Invalid theme", result)
|
||||
}
|
||||
|
||||
// validateOutputFormat validates the output format field.
|
||||
func (v *ConfigValidator) validateOutputFormat(format string, result *ValidationResult) {
|
||||
validFormats := []string{"md", "html", "json", "asciidoc"}
|
||||
validFormats := appconstants.GetSupportedOutputFormats()
|
||||
|
||||
found := false
|
||||
for _, validFormat := range validFormats {
|
||||
if format == validFormat {
|
||||
found = true
|
||||
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !found {
|
||||
result.Errors = append(result.Errors, ValidationError{
|
||||
Field: "output_format",
|
||||
Message: "Invalid output format",
|
||||
Value: format,
|
||||
})
|
||||
result.Suggestions = append(result.Suggestions,
|
||||
"Valid formats: "+strings.Join(validFormats, ", "))
|
||||
}
|
||||
v.validateFieldInList(appconstants.ConfigKeyOutputFormat, format, validFormats, "Invalid output format", result)
|
||||
}
|
||||
|
||||
// validateOutputDir validates the output directory field.
|
||||
func (v *ConfigValidator) validateOutputDir(dir string, result *ValidationResult) {
|
||||
if dir == "" {
|
||||
result.Errors = append(result.Errors, ValidationError{
|
||||
Field: "output_dir",
|
||||
Field: appconstants.ConfigKeyOutputDir,
|
||||
Message: "Output directory cannot be empty",
|
||||
Value: dir,
|
||||
})
|
||||
@@ -270,24 +245,20 @@ func (v *ConfigValidator) validateOutputDir(dir string, result *ValidationResult
|
||||
parent := filepath.Dir(dir)
|
||||
if parent != "." {
|
||||
if _, err := os.Stat(parent); os.IsNotExist(err) {
|
||||
result.Warnings = append(result.Warnings, ValidationWarning{
|
||||
Field: "output_dir",
|
||||
Message: "Parent directory does not exist",
|
||||
Value: dir,
|
||||
})
|
||||
result.Suggestions = append(result.Suggestions,
|
||||
addWarningWithSuggestion(result,
|
||||
appconstants.ConfigKeyOutputDir,
|
||||
"Parent directory does not exist",
|
||||
dir,
|
||||
"Ensure the parent directory exists or will be created")
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Absolute path - check if it exists
|
||||
if _, err := os.Stat(dir); os.IsNotExist(err) {
|
||||
result.Warnings = append(result.Warnings, ValidationWarning{
|
||||
Field: "output_dir",
|
||||
Message: "Directory does not exist",
|
||||
Value: dir,
|
||||
})
|
||||
result.Suggestions = append(result.Suggestions,
|
||||
addWarningWithSuggestion(result,
|
||||
appconstants.ConfigKeyOutputDir,
|
||||
"Directory does not exist",
|
||||
dir,
|
||||
"Directory will be created if it doesn't exist")
|
||||
}
|
||||
}
|
||||
@@ -321,30 +292,32 @@ func (v *ConfigValidator) validateGitHubToken(token string, result *ValidationRe
|
||||
"Consider using GITHUB_TOKEN environment variable instead")
|
||||
}
|
||||
|
||||
// validatePermissionValue validates a single permission value and updates the result.
|
||||
func (v *ConfigValidator) validatePermissionValue(
|
||||
permission, value string,
|
||||
validValues []string,
|
||||
result *ValidationResult,
|
||||
) {
|
||||
if !v.isValueInList(value, validValues) {
|
||||
result.Errors = append(result.Errors, ValidationError{
|
||||
Field: "permissions." + permission,
|
||||
Message: "Invalid permission value",
|
||||
Value: value,
|
||||
})
|
||||
result.Suggestions = append(result.Suggestions,
|
||||
fmt.Sprintf("Valid values for %s: %s", permission, strings.Join(validValues, ", ")))
|
||||
}
|
||||
}
|
||||
|
||||
// validatePermissions validates the permissions field.
|
||||
func (v *ConfigValidator) validatePermissions(permissions map[string]string, result *ValidationResult) {
|
||||
if len(permissions) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
validPermissions := map[string][]string{
|
||||
"actions": {"read", "write"},
|
||||
"checks": {"read", "write"},
|
||||
"contents": {"read", "write"},
|
||||
"deployments": {"read", "write"},
|
||||
"id-token": {"write"},
|
||||
"issues": {"read", "write"},
|
||||
"discussions": {"read", "write"},
|
||||
"packages": {"read", "write"},
|
||||
"pull-requests": {"read", "write"},
|
||||
"repository-projects": {"read", "write"},
|
||||
"security-events": {"read", "write"},
|
||||
"statuses": {"read", "write"},
|
||||
}
|
||||
|
||||
for permission, value := range permissions {
|
||||
// Check if permission is valid
|
||||
validValues, permissionExists := validPermissions[permission]
|
||||
validValues, permissionExists := validPermissionsMap[permission]
|
||||
if !permissionExists {
|
||||
result.Warnings = append(result.Warnings, ValidationWarning{
|
||||
Field: "permissions",
|
||||
@@ -356,24 +329,7 @@ func (v *ConfigValidator) validatePermissions(permissions map[string]string, res
|
||||
}
|
||||
|
||||
// Check if value is valid
|
||||
validValue := false
|
||||
for _, validVal := range validValues {
|
||||
if value == validVal {
|
||||
validValue = true
|
||||
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !validValue {
|
||||
result.Errors = append(result.Errors, ValidationError{
|
||||
Field: "permissions",
|
||||
Message: "Invalid value for permission " + permission,
|
||||
Value: value,
|
||||
})
|
||||
result.Suggestions = append(result.Suggestions,
|
||||
fmt.Sprintf("Valid values for %s: %s", permission, strings.Join(validValues, ", ")))
|
||||
}
|
||||
v.validatePermissionValue(permission, value, validValues, result)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -392,31 +348,22 @@ func (v *ConfigValidator) validateRunsOn(runsOn []string, result *ValidationResu
|
||||
}
|
||||
|
||||
validRunners := []string{
|
||||
"ubuntu-latest", "ubuntu-22.04", "ubuntu-20.04",
|
||||
"windows-latest", "windows-2022", "windows-2019",
|
||||
"macos-latest", "macos-13", "macos-12", "macos-11",
|
||||
appconstants.RunnerUbuntuLatest, "ubuntu-22.04", "ubuntu-20.04",
|
||||
appconstants.RunnerWindowsLatest, "windows-2022", "windows-2019",
|
||||
appconstants.RunnerMacosLatest, "macos-13", "macos-12", "macos-11",
|
||||
}
|
||||
|
||||
for _, runner := range runsOn {
|
||||
// Check if it's a GitHub-hosted runner
|
||||
isValid := false
|
||||
for _, validRunner := range validRunners {
|
||||
if runner == validRunner {
|
||||
isValid = true
|
||||
|
||||
break
|
||||
}
|
||||
}
|
||||
isValid := v.isValueInList(runner, validRunners)
|
||||
|
||||
// If not a standard runner, it might be self-hosted
|
||||
if !isValid {
|
||||
if !strings.HasPrefix(runner, "self-hosted") {
|
||||
result.Warnings = append(result.Warnings, ValidationWarning{
|
||||
Field: "runs_on",
|
||||
Message: "Unknown runner: " + runner,
|
||||
Value: runner,
|
||||
})
|
||||
result.Suggestions = append(result.Suggestions,
|
||||
addWarningWithSuggestion(result,
|
||||
"runs_on",
|
||||
"Unknown runner: "+runner,
|
||||
runner,
|
||||
"Ensure the runner is available in your GitHub organization")
|
||||
}
|
||||
}
|
||||
@@ -457,6 +404,11 @@ func (v *ConfigValidator) validateVariables(variables map[string]string, result
|
||||
}
|
||||
}
|
||||
|
||||
// isValueInList checks if a value exists in a list of valid options.
|
||||
func (v *ConfigValidator) isValueInList(value string, validOptions []string) bool {
|
||||
return slices.Contains(validOptions, value)
|
||||
}
|
||||
|
||||
// isValidGitHubName checks if a name follows GitHub naming rules.
|
||||
func (v *ConfigValidator) isValidGitHubName(name string) bool {
|
||||
if len(name) == 0 || len(name) > 39 {
|
||||
|
||||
60
internal/wizard/validator_helper.go
Normal file
60
internal/wizard/validator_helper.go
Normal file
@@ -0,0 +1,60 @@
|
||||
package wizard
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// validateFieldWithEmptyCheck is a generic helper for fields that:
|
||||
// - Allow empty values (with optional warning)
|
||||
// - Validate non-empty values with a custom validator function
|
||||
// - Add error and optional suggestion if validation fails.
|
||||
func (v *ConfigValidator) validateFieldWithEmptyCheck(
|
||||
field, fieldValue string,
|
||||
isValid func(string) bool,
|
||||
emptyWarning, errorMsg, suggestion string,
|
||||
result *ValidationResult,
|
||||
) {
|
||||
if fieldValue == "" {
|
||||
if emptyWarning != "" {
|
||||
result.Warnings = append(result.Warnings, ValidationWarning{
|
||||
Field: field,
|
||||
Message: emptyWarning,
|
||||
Value: fieldValue,
|
||||
})
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if !isValid(fieldValue) {
|
||||
result.Errors = append(result.Errors, ValidationError{
|
||||
Field: field,
|
||||
Message: errorMsg,
|
||||
Value: fieldValue,
|
||||
})
|
||||
|
||||
if suggestion != "" {
|
||||
result.Suggestions = append(result.Suggestions, suggestion)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// validateFieldInList is a generic helper for fields that must be
|
||||
// one of a predefined list of valid values.
|
||||
func (v *ConfigValidator) validateFieldInList(
|
||||
field, fieldValue string,
|
||||
validValues []string,
|
||||
errorMsg string,
|
||||
result *ValidationResult,
|
||||
) {
|
||||
if !v.isValueInList(fieldValue, validValues) {
|
||||
result.Errors = append(result.Errors, ValidationError{
|
||||
Field: field,
|
||||
Message: errorMsg,
|
||||
Value: fieldValue,
|
||||
})
|
||||
result.Suggestions = append(result.Suggestions,
|
||||
fmt.Sprintf("Valid %ss: %s", field, strings.Join(validValues, ", ")))
|
||||
}
|
||||
}
|
||||
@@ -6,10 +6,46 @@ import (
|
||||
"github.com/ivuorinen/gh-action-readme/internal"
|
||||
)
|
||||
|
||||
func TestConfigValidator_ValidateConfig(t *testing.T) {
|
||||
// newTestValidator creates a ConfigValidator for testing with quiet output.
|
||||
// Reduces duplication across validator tests.
|
||||
func newTestValidator() *ConfigValidator {
|
||||
output := internal.NewColoredOutput(true)
|
||||
|
||||
return NewConfigValidator(output)
|
||||
}
|
||||
|
||||
// validationTestCase defines a test case for string validation methods.
|
||||
type validationTestCase struct {
|
||||
name string
|
||||
input string
|
||||
want bool
|
||||
}
|
||||
|
||||
// runValidationTests is a generic helper for testing validator methods that take a string and return bool.
|
||||
// This eliminates duplication across isValidGitHubName, isValidSemanticVersion, isValidGitHubToken, etc.
|
||||
func runValidationTests(
|
||||
t *testing.T,
|
||||
tests []validationTestCase,
|
||||
validatorFunc func(string) bool,
|
||||
funcName string,
|
||||
) {
|
||||
t.Helper()
|
||||
t.Parallel()
|
||||
output := internal.NewColoredOutput(true) // quiet mode for testing
|
||||
validator := NewConfigValidator(output)
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
got := validatorFunc(tt.input)
|
||||
if got != tt.want {
|
||||
t.Errorf("%s(%q) = %v, want %v", funcName, tt.input, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigValidatorValidateConfig(t *testing.T) {
|
||||
t.Parallel()
|
||||
validator := newTestValidator()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -93,10 +129,9 @@ func TestConfigValidator_ValidateConfig(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigValidator_ValidateField(t *testing.T) {
|
||||
func TestConfigValidatorValidateField(t *testing.T) {
|
||||
t.Parallel()
|
||||
output := internal.NewColoredOutput(true)
|
||||
validator := NewConfigValidator(output)
|
||||
validator := newTestValidator()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -128,16 +163,10 @@ func TestConfigValidator_ValidateField(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigValidator_isValidGitHubName(t *testing.T) {
|
||||
t.Parallel()
|
||||
output := internal.NewColoredOutput(true)
|
||||
validator := NewConfigValidator(output)
|
||||
func TestConfigValidatorIsValidGitHubName(t *testing.T) {
|
||||
validator := newTestValidator()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
want bool
|
||||
}{
|
||||
tests := []validationTestCase{
|
||||
{"valid name", "test-org", true},
|
||||
{"valid name with numbers", "test123", true},
|
||||
{"valid name with underscore", "test_org", true},
|
||||
@@ -149,27 +178,13 @@ func TestConfigValidator_isValidGitHubName(t *testing.T) {
|
||||
{"very long name", "this-is-a-very-long-organization-name-that-exceeds-the-limit", false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
got := validator.isValidGitHubName(tt.input)
|
||||
if got != tt.want {
|
||||
t.Errorf("isValidGitHubName(%q) = %v, want %v", tt.input, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
runValidationTests(t, tests, validator.isValidGitHubName, "isValidGitHubName")
|
||||
}
|
||||
|
||||
func TestConfigValidator_isValidSemanticVersion(t *testing.T) {
|
||||
t.Parallel()
|
||||
output := internal.NewColoredOutput(true)
|
||||
validator := NewConfigValidator(output)
|
||||
func TestConfigValidatorIsValidSemanticVersion(t *testing.T) {
|
||||
validator := newTestValidator()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
want bool
|
||||
}{
|
||||
tests := []validationTestCase{
|
||||
{"valid version", "1.0.0", true},
|
||||
{"valid version with pre-release", "1.0.0-alpha", true},
|
||||
{"valid version with build", "1.0.0+build.1", true},
|
||||
@@ -180,27 +195,13 @@ func TestConfigValidator_isValidSemanticVersion(t *testing.T) {
|
||||
{"empty version", "", false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
got := validator.isValidSemanticVersion(tt.input)
|
||||
if got != tt.want {
|
||||
t.Errorf("isValidSemanticVersion(%q) = %v, want %v", tt.input, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
runValidationTests(t, tests, validator.isValidSemanticVersion, "isValidSemanticVersion")
|
||||
}
|
||||
|
||||
func TestConfigValidator_isValidGitHubToken(t *testing.T) {
|
||||
t.Parallel()
|
||||
output := internal.NewColoredOutput(true)
|
||||
validator := NewConfigValidator(output)
|
||||
func TestConfigValidatorIsValidGitHubToken(t *testing.T) {
|
||||
validator := newTestValidator()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
want bool
|
||||
}{
|
||||
tests := []validationTestCase{
|
||||
{"classic token", "ghp_1234567890abcdef1234567890abcdef12345678", true},
|
||||
{"fine-grained token", "github_pat_1234567890abcdef", true},
|
||||
{"app token", "ghs_1234567890abcdef", true},
|
||||
@@ -211,27 +212,13 @@ func TestConfigValidator_isValidGitHubToken(t *testing.T) {
|
||||
{"empty token", "", false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
got := validator.isValidGitHubToken(tt.input)
|
||||
if got != tt.want {
|
||||
t.Errorf("isValidGitHubToken(%q) = %v, want %v", tt.input, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
runValidationTests(t, tests, validator.isValidGitHubToken, "isValidGitHubToken")
|
||||
}
|
||||
|
||||
func TestConfigValidator_isValidVariableName(t *testing.T) {
|
||||
t.Parallel()
|
||||
output := internal.NewColoredOutput(true)
|
||||
validator := NewConfigValidator(output)
|
||||
func TestConfigValidatorIsValidVariableName(t *testing.T) {
|
||||
validator := newTestValidator()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
want bool
|
||||
}{
|
||||
tests := []validationTestCase{
|
||||
{"valid name", "MY_VAR", true},
|
||||
{"valid name with underscore", "_MY_VAR", true},
|
||||
{"valid name lowercase", "my_var", true},
|
||||
@@ -243,13 +230,5 @@ func TestConfigValidator_isValidVariableName(t *testing.T) {
|
||||
{"empty name", "", false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
got := validator.isValidVariableName(tt.input)
|
||||
if got != tt.want {
|
||||
t.Errorf("isValidVariableName(%q) = %v, want %v", tt.input, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
runValidationTests(t, tests, validator.isValidVariableName, "isValidVariableName")
|
||||
}
|
||||
|
||||
1
internal/wizard/validator_test_helpers.go
Normal file
1
internal/wizard/validator_test_helpers.go
Normal file
@@ -0,0 +1 @@
|
||||
package wizard
|
||||
@@ -141,7 +141,7 @@ func (w *ConfigWizard) configureThemeSelection() {
|
||||
// configureOutputFormat handles output format selection.
|
||||
func (w *ConfigWizard) configureOutputFormat() {
|
||||
w.output.Info("\nAvailable output formats:")
|
||||
formats := []string{"md", "html", "json", "asciidoc"}
|
||||
formats := appconstants.GetSupportedOutputFormats()
|
||||
|
||||
w.displayFormatOptions(formats)
|
||||
|
||||
@@ -165,11 +165,11 @@ func (w *ConfigWizard) getAvailableThemes() []struct {
|
||||
name string
|
||||
desc string
|
||||
}{
|
||||
{"default", "Original simple template"},
|
||||
{"github", "GitHub-style with badges and collapsible sections"},
|
||||
{"gitlab", "GitLab-focused with CI/CD examples"},
|
||||
{"minimal", "Clean and concise documentation"},
|
||||
{"professional", "Comprehensive with troubleshooting and ToC"},
|
||||
{appconstants.ThemeDefault, "Original simple template"},
|
||||
{appconstants.ThemeGitHub, "GitHub-style with badges and collapsible sections"},
|
||||
{appconstants.ThemeGitLab, "GitLab-focused with CI/CD examples"},
|
||||
{appconstants.ThemeMinimal, "Clean and concise documentation"},
|
||||
{appconstants.ThemeProfessional, "Comprehensive with troubleshooting and ToC"},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -357,15 +357,20 @@ func (w *ConfigWizard) promptYesNo(prompt string, defaultValue bool) bool {
|
||||
|
||||
// findActionFiles discovers action files in the given directory.
|
||||
func (w *ConfigWizard) findActionFiles(dir string) []string {
|
||||
var actionFiles []string
|
||||
|
||||
// Check for action.yml and action.yaml
|
||||
for _, filename := range []string{"action.yml", "action.yaml"} {
|
||||
actionPath := filepath.Join(dir, filename)
|
||||
if _, err := os.Stat(actionPath); err == nil {
|
||||
actionFiles = append(actionFiles, actionPath)
|
||||
// Check for path traversal attempts in the raw input before cleaning
|
||||
for _, component := range strings.Split(filepath.ToSlash(dir), "/") {
|
||||
if component == ".." {
|
||||
return []string{} // Return empty for invalid paths
|
||||
}
|
||||
}
|
||||
|
||||
return actionFiles
|
||||
// Validate and clean the input path
|
||||
cleanDir := filepath.Clean(dir)
|
||||
// Verify Clean didn't change the path (indicates normalization/traversal)
|
||||
if cleanDir != dir {
|
||||
return []string{} // Return empty for paths with traversal
|
||||
}
|
||||
|
||||
// Check for action.yml and action.yaml using validated path
|
||||
return internal.DiscoverActionFilesNonRecursive(cleanDir)
|
||||
}
|
||||
|
||||
1200
internal/wizard/wizard_test.go
Normal file
1200
internal/wizard/wizard_test.go
Normal file
File diff suppressed because it is too large
Load Diff
474
main.go
474
main.go
@@ -2,11 +2,10 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/schollz/progressbar/v3"
|
||||
@@ -35,6 +34,37 @@ var (
|
||||
quiet bool
|
||||
)
|
||||
|
||||
// InputReader interface for reading user input (enables testing).
|
||||
type InputReader interface {
|
||||
ReadLine() (string, error)
|
||||
}
|
||||
|
||||
// StdinReader reads from actual stdin.
|
||||
type StdinReader struct{}
|
||||
|
||||
func (r *StdinReader) ReadLine() (string, error) {
|
||||
var response string
|
||||
_, err := fmt.Scanln(&response)
|
||||
|
||||
return strings.TrimSpace(response), err
|
||||
}
|
||||
|
||||
// TestInputReader allows injecting test responses for testing.
|
||||
type TestInputReader struct {
|
||||
responses []string
|
||||
index int
|
||||
}
|
||||
|
||||
func (r *TestInputReader) ReadLine() (string, error) {
|
||||
if r.index >= len(r.responses) {
|
||||
return "", errors.New("no more test responses")
|
||||
}
|
||||
response := r.responses[r.index]
|
||||
r.index++
|
||||
|
||||
return response, nil
|
||||
}
|
||||
|
||||
// Helper functions to reduce duplication.
|
||||
|
||||
func createOutputManager(quiet bool) *internal.ColoredOutput {
|
||||
@@ -89,13 +119,52 @@ func createAnalyzer(generator *internal.Generator, output *internal.ColoredOutpu
|
||||
return helpers.CreateAnalyzer(generator, output)
|
||||
}
|
||||
|
||||
// wrapHandlerWithErrorHandling converts error-returning handler to Cobra handler.
|
||||
// This allows handlers to return errors for testing while maintaining Cobra compatibility.
|
||||
func wrapHandlerWithErrorHandling(handler func(*cobra.Command, []string) error) func(*cobra.Command, []string) {
|
||||
return func(cmd *cobra.Command, args []string) {
|
||||
// Ensure globalConfig is initialized (important for testing)
|
||||
if globalConfig == nil {
|
||||
globalConfig = internal.DefaultAppConfig()
|
||||
}
|
||||
|
||||
if err := handler(cmd, args); err != nil {
|
||||
output := createOutputManager(globalConfig.Quiet)
|
||||
output.Error(err.Error())
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// wrapError wraps an error with a message constant.
|
||||
// This is a helper to reduce duplication of the fmt.Errorf("%s: %w", msg, err) pattern.
|
||||
func wrapError(msgConstant string, err error) error {
|
||||
return fmt.Errorf("%s: %w", msgConstant, err)
|
||||
}
|
||||
|
||||
// handleNoFilesFoundError handles errors where no action files are found, showing a warning instead of failing.
|
||||
// Returns nil if the error is about no files found (after showing warning), otherwise returns the original error.
|
||||
func handleNoFilesFoundError(err error, output *internal.ColoredOutput) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
if strings.Contains(err.Error(), appconstants.ErrNoActionFilesFound) {
|
||||
output.Warning(appconstants.ErrNoActionFilesFound)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
func main() {
|
||||
rootCmd := &cobra.Command{
|
||||
Use: "gh-action-readme",
|
||||
Short: "Auto-generate beautiful README and HTML documentation for GitHub Actions.",
|
||||
Long: `gh-action-readme is a CLI tool for parsing one or many action.yml files and ` +
|
||||
`generating informative, modern, and customizable documentation.`,
|
||||
PersistentPreRun: initConfig,
|
||||
PersistentPreRunE: initConfig,
|
||||
}
|
||||
|
||||
// Global flags
|
||||
@@ -141,14 +210,14 @@ func main() {
|
||||
}
|
||||
}
|
||||
|
||||
func initConfig(_ *cobra.Command, _ []string) {
|
||||
func initConfig(_ *cobra.Command, _ []string) error {
|
||||
var err error
|
||||
|
||||
// Use ConfigurationLoader for loading global configuration
|
||||
loader := internal.NewConfigurationLoader()
|
||||
globalConfig, err = loader.LoadGlobalConfig(configFile)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to initialize configuration: %v", err)
|
||||
return fmt.Errorf("failed to initialize configuration: %w", err)
|
||||
}
|
||||
|
||||
// Override with command line flags
|
||||
@@ -159,6 +228,8 @@ func initConfig(_ *cobra.Command, _ []string) {
|
||||
globalConfig.Quiet = true
|
||||
globalConfig.Verbose = false // quiet overrides verbose
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func newGenCmd() *cobra.Command {
|
||||
@@ -175,14 +246,24 @@ Examples:
|
||||
gh-action-readme gen -f html --output custom.html testdata/action/
|
||||
gh-action-readme gen --output docs/action1.html testdata/action1/`,
|
||||
Args: cobra.MaximumNArgs(1),
|
||||
Run: genHandler,
|
||||
Run: wrapHandlerWithErrorHandling(genHandler),
|
||||
}
|
||||
|
||||
cmd.Flags().StringP(appconstants.FlagOutputFormat, "f", "md", "output format: md, html, json, asciidoc")
|
||||
cmd.Flags().StringP(
|
||||
appconstants.FlagOutputFormat,
|
||||
"f",
|
||||
appconstants.OutputFormatMarkdown,
|
||||
"output format: md, html, json, asciidoc",
|
||||
)
|
||||
cmd.Flags().StringP(appconstants.FlagOutputDir, "o", ".", "output directory")
|
||||
cmd.Flags().StringP(appconstants.FlagOutput, "", "", "custom output filename (overrides default naming)")
|
||||
cmd.Flags().StringP(appconstants.ConfigKeyTheme, "t", "", "template theme: github, gitlab, minimal, professional")
|
||||
cmd.Flags().BoolP(appconstants.FlagRecursive, "r", false, "search for action.yml files recursively")
|
||||
cmd.Flags().StringSlice(
|
||||
appconstants.FlagIgnoreDirs,
|
||||
[]string{},
|
||||
"directories to ignore during recursive discovery (comma-separated)",
|
||||
)
|
||||
|
||||
return cmd
|
||||
}
|
||||
@@ -191,7 +272,7 @@ func newValidateCmd() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "validate",
|
||||
Short: "Validate action.yml files and optionally autofill missing fields.",
|
||||
Run: validateHandler,
|
||||
Run: wrapHandlerWithErrorHandling(validateHandler),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -203,9 +284,9 @@ func newSchemaCmd() *cobra.Command {
|
||||
}
|
||||
}
|
||||
|
||||
func genHandler(cmd *cobra.Command, args []string) {
|
||||
output := createOutputManager(globalConfig.Quiet)
|
||||
|
||||
// resolveAndValidateTargetPath resolves the target path from arguments or current directory,
|
||||
// validates it exists, and returns the absolute path and file info.
|
||||
func resolveAndValidateTargetPath(args []string) (string, os.FileInfo, error) {
|
||||
// Determine target path from arguments or current directory
|
||||
var targetPath string
|
||||
if len(args) > 0 {
|
||||
@@ -214,23 +295,35 @@ func genHandler(cmd *cobra.Command, args []string) {
|
||||
var err error
|
||||
targetPath, err = helpers.GetCurrentDir()
|
||||
if err != nil {
|
||||
output.Error(appconstants.ErrErrorGettingCurrentDir, err)
|
||||
os.Exit(1)
|
||||
return "", nil, wrapError(appconstants.ErrErrorGettingCurrentDir, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve target path to absolute path
|
||||
absTargetPath, err := filepath.Abs(targetPath)
|
||||
if err != nil {
|
||||
output.Error("Error resolving path %s: %v", targetPath, err)
|
||||
os.Exit(1)
|
||||
return "", nil, fmt.Errorf("error resolving path %s: %w", targetPath, err)
|
||||
}
|
||||
|
||||
// Check if target exists
|
||||
info, err := os.Stat(absTargetPath)
|
||||
if err != nil {
|
||||
output.Error("Path does not exist: %s", targetPath)
|
||||
os.Exit(1)
|
||||
return "", nil, fmt.Errorf("path does not exist: %s", targetPath)
|
||||
}
|
||||
|
||||
return absTargetPath, info, nil
|
||||
}
|
||||
|
||||
func genHandler(cmd *cobra.Command, args []string) error {
|
||||
// Ensure globalConfig is initialized
|
||||
if globalConfig == nil {
|
||||
globalConfig = internal.DefaultAppConfig()
|
||||
}
|
||||
|
||||
// Resolve and validate target path
|
||||
absTargetPath, info, err := resolveAndValidateTargetPath(args)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var workingDir string
|
||||
@@ -241,52 +334,60 @@ func genHandler(cmd *cobra.Command, args []string) {
|
||||
workingDir = absTargetPath
|
||||
generator := internal.NewGenerator(globalConfig) // Temporary generator for discovery
|
||||
recursive, _ := cmd.Flags().GetBool(appconstants.FlagRecursive)
|
||||
|
||||
// Get ignored directories from CLI flag or use config defaults
|
||||
ignoredDirs, _ := cmd.Flags().GetStringSlice(appconstants.FlagIgnoreDirs)
|
||||
if len(ignoredDirs) == 0 {
|
||||
ignoredDirs = globalConfig.IgnoredDirectories
|
||||
}
|
||||
|
||||
actionFiles, err = generator.DiscoverActionFilesWithValidation(
|
||||
workingDir,
|
||||
recursive,
|
||||
ignoredDirs,
|
||||
"documentation generation",
|
||||
)
|
||||
if err != nil {
|
||||
os.Exit(1)
|
||||
return fmt.Errorf(appconstants.ErrFailedToDiscoverActionFiles, err)
|
||||
}
|
||||
} else {
|
||||
// Target is a file - validate it's an action file
|
||||
lowerPath := strings.ToLower(absTargetPath)
|
||||
if !strings.HasSuffix(lowerPath, ".yml") && !strings.HasSuffix(lowerPath, ".yaml") {
|
||||
output.Error("File must be a YAML file (.yml or .yaml): %s", targetPath)
|
||||
os.Exit(1)
|
||||
return fmt.Errorf("file must be a YAML file (.yml or .yaml): %s", absTargetPath)
|
||||
}
|
||||
workingDir = filepath.Dir(absTargetPath)
|
||||
actionFiles = []string{absTargetPath}
|
||||
}
|
||||
|
||||
repoRoot := helpers.FindGitRepoRoot(workingDir)
|
||||
config := loadGenConfig(repoRoot, workingDir)
|
||||
config, err := loadGenConfig(repoRoot, workingDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
applyGlobalFlags(config)
|
||||
applyCommandFlags(cmd, config)
|
||||
|
||||
generator := internal.NewGenerator(config)
|
||||
logConfigInfo(generator, config, repoRoot)
|
||||
|
||||
processActionFiles(generator, actionFiles)
|
||||
return processActionFiles(generator, actionFiles)
|
||||
}
|
||||
|
||||
// loadGenConfig loads multi-level configuration using ConfigurationLoader.
|
||||
func loadGenConfig(repoRoot, currentDir string) *internal.AppConfig {
|
||||
func loadGenConfig(repoRoot, currentDir string) (*internal.AppConfig, error) {
|
||||
loader := internal.NewConfigurationLoader()
|
||||
config, err := loader.LoadConfiguration(configFile, repoRoot, currentDir)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error loading configuration: %v\n", err)
|
||||
os.Exit(1)
|
||||
return nil, fmt.Errorf("error loading configuration: %w", err)
|
||||
}
|
||||
|
||||
// Validate the loaded configuration
|
||||
if err := loader.ValidateConfiguration(config); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Configuration validation error: %v\n", err)
|
||||
os.Exit(1)
|
||||
return nil, fmt.Errorf("configuration validation error: %w", err)
|
||||
}
|
||||
|
||||
return config
|
||||
return config, nil
|
||||
}
|
||||
|
||||
// applyGlobalFlags applies global verbose/quiet flags.
|
||||
@@ -307,7 +408,7 @@ func applyCommandFlags(cmd *cobra.Command, config *internal.AppConfig) {
|
||||
outputFilename, _ := cmd.Flags().GetString(appconstants.FlagOutput)
|
||||
theme, _ := cmd.Flags().GetString(appconstants.ConfigKeyTheme)
|
||||
|
||||
if outputFormat != "md" {
|
||||
if outputFormat != appconstants.OutputFormatMarkdown {
|
||||
config.OutputFormat = outputFormat
|
||||
}
|
||||
if outputDir != "." {
|
||||
@@ -332,44 +433,44 @@ func logConfigInfo(generator *internal.Generator, config *internal.AppConfig, re
|
||||
}
|
||||
|
||||
// processActionFiles processes discovered files.
|
||||
func processActionFiles(generator *internal.Generator, actionFiles []string) {
|
||||
func processActionFiles(generator *internal.Generator, actionFiles []string) error {
|
||||
if err := generator.ProcessBatch(actionFiles); err != nil {
|
||||
generator.Output.Error("Error during generation: %v", err)
|
||||
os.Exit(1)
|
||||
return fmt.Errorf("error during generation: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateHandler(_ *cobra.Command, _ []string) {
|
||||
func validateHandler(_ *cobra.Command, _ []string) error {
|
||||
// Ensure globalConfig is initialized
|
||||
if globalConfig == nil {
|
||||
globalConfig = internal.DefaultAppConfig()
|
||||
}
|
||||
|
||||
currentDir, err := helpers.GetCurrentDir()
|
||||
if err != nil {
|
||||
_, errorHandler := setupOutputAndErrorHandling()
|
||||
errorHandler.HandleSimpleError("Unable to determine current directory", err)
|
||||
return fmt.Errorf("unable to determine current directory: %w", err)
|
||||
}
|
||||
|
||||
generator := internal.NewGenerator(globalConfig)
|
||||
actionFiles, err := generator.DiscoverActionFilesWithValidation(
|
||||
currentDir,
|
||||
true,
|
||||
globalConfig.IgnoredDirectories,
|
||||
"validation",
|
||||
) // Recursive for validation
|
||||
if err != nil {
|
||||
os.Exit(1)
|
||||
return fmt.Errorf(appconstants.ErrFailedToDiscoverActionFiles, err)
|
||||
}
|
||||
|
||||
// Validate the discovered files
|
||||
if err := generator.ValidateFiles(actionFiles); err != nil {
|
||||
generator.Output.ErrorWithContext(
|
||||
appconstants.ErrCodeValidation,
|
||||
"validation failed",
|
||||
map[string]string{
|
||||
"files_count": strconv.Itoa(len(actionFiles)),
|
||||
appconstants.ContextKeyError: err.Error(),
|
||||
},
|
||||
)
|
||||
os.Exit(1)
|
||||
return fmt.Errorf("validation failed for %d files: %w", len(actionFiles), err)
|
||||
}
|
||||
|
||||
generator.Output.Success("\nAll validations passed successfully!")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func schemaHandler(_ *cobra.Command, _ []string) {
|
||||
@@ -403,14 +504,14 @@ func newConfigCmd() *cobra.Command {
|
||||
cmd.AddCommand(&cobra.Command{
|
||||
Use: "init",
|
||||
Short: "Initialize default configuration file",
|
||||
Run: configInitHandler,
|
||||
Run: wrapHandlerWithErrorHandling(configInitHandler),
|
||||
})
|
||||
|
||||
initCmd := &cobra.Command{
|
||||
Use: "wizard",
|
||||
Short: "Interactive configuration wizard",
|
||||
Long: "Launch an interactive wizard to set up your configuration step by step",
|
||||
Run: configWizardHandler,
|
||||
Run: wrapHandlerWithErrorHandling(configWizardHandler),
|
||||
}
|
||||
initCmd.Flags().String(appconstants.FlagFormat, "yaml", "Export format: yaml, json, toml")
|
||||
initCmd.Flags().String(appconstants.FlagOutput, "", "Output path (default: XDG config directory)")
|
||||
@@ -431,31 +532,36 @@ func newConfigCmd() *cobra.Command {
|
||||
return cmd
|
||||
}
|
||||
|
||||
func configInitHandler(_ *cobra.Command, _ []string) {
|
||||
func configInitHandler(_ *cobra.Command, _ []string) error {
|
||||
// Ensure globalConfig is initialized
|
||||
if globalConfig == nil {
|
||||
globalConfig = internal.DefaultAppConfig()
|
||||
}
|
||||
|
||||
output := createOutputManager(globalConfig.Quiet)
|
||||
|
||||
// Check if config already exists
|
||||
configPath, err := internal.GetConfigPath()
|
||||
if err != nil {
|
||||
output.Error("Failed to get config path: %v", err)
|
||||
os.Exit(1)
|
||||
return fmt.Errorf("failed to get config path: %w", err)
|
||||
}
|
||||
|
||||
if _, err := os.Stat(configPath); err == nil {
|
||||
output.Warning("Configuration file already exists at: %s", configPath)
|
||||
output.Info("Use 'gh-action-readme config show' to view current configuration")
|
||||
|
||||
return
|
||||
return nil
|
||||
}
|
||||
|
||||
// Create default config
|
||||
if err := internal.WriteDefaultConfig(); err != nil {
|
||||
output.Error("Failed to write default configuration: %v", err)
|
||||
os.Exit(1)
|
||||
return fmt.Errorf("failed to write default configuration: %w", err)
|
||||
}
|
||||
|
||||
output.Success("Created default configuration at: %s", configPath)
|
||||
output.Info("Edit this file to customize your settings")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func configShowHandler(_ *cobra.Command, _ []string) {
|
||||
@@ -507,19 +613,19 @@ func newDepsCmd() *cobra.Command {
|
||||
cmd.AddCommand(&cobra.Command{
|
||||
Use: "list",
|
||||
Short: "List all dependencies in action files",
|
||||
Run: depsListHandler,
|
||||
Run: wrapHandlerWithErrorHandling(depsListHandler),
|
||||
})
|
||||
|
||||
cmd.AddCommand(&cobra.Command{
|
||||
Use: "security",
|
||||
Short: "Analyze dependency security (pinned vs floating versions)",
|
||||
Run: depsSecurityHandler,
|
||||
Run: wrapHandlerWithErrorHandling(depsSecurityHandler),
|
||||
})
|
||||
|
||||
cmd.AddCommand(&cobra.Command{
|
||||
Use: "outdated",
|
||||
Short: "Check for outdated dependencies",
|
||||
Run: depsOutdatedHandler,
|
||||
Run: wrapHandlerWithErrorHandling(depsOutdatedHandler),
|
||||
})
|
||||
|
||||
cmd.AddCommand(&cobra.Command{
|
||||
@@ -532,18 +638,18 @@ func newDepsCmd() *cobra.Command {
|
||||
Use: "upgrade",
|
||||
Short: "Upgrade dependencies with interactive or CI mode",
|
||||
Long: "Upgrade dependencies to latest versions. Use --ci for automated pinned updates.",
|
||||
Run: depsUpgradeHandler,
|
||||
Run: wrapHandlerWithErrorHandling(depsUpgradeHandler),
|
||||
}
|
||||
upgradeCmd.Flags().Bool("ci", false, "CI/CD mode: automatically pin all updates to commit SHAs")
|
||||
upgradeCmd.Flags().Bool(appconstants.FlagCI, false, "CI/CD mode: automatically pin all updates to commit SHAs")
|
||||
upgradeCmd.Flags().Bool(appconstants.InputAll, false, "Update all outdated dependencies without prompts")
|
||||
upgradeCmd.Flags().Bool(appconstants.InputDryRun, false, "Show what would be updated without making changes")
|
||||
cmd.AddCommand(upgradeCmd)
|
||||
|
||||
pinCmd := &cobra.Command{
|
||||
Use: "pin",
|
||||
Use: appconstants.CommandPin,
|
||||
Short: "Pin floating versions to specific commits",
|
||||
Long: "Convert floating versions (like @v4) to pinned commit SHAs with version comments.",
|
||||
Run: depsUpgradeHandler, // Uses same handler with different flags
|
||||
Run: wrapHandlerWithErrorHandling(depsUpgradeHandler), // Uses same handler with different flags
|
||||
}
|
||||
pinCmd.Flags().Bool(appconstants.InputAll, false, "Pin all floating dependencies")
|
||||
pinCmd.Flags().Bool(appconstants.InputDryRun, false, "Show what would be pinned without making changes")
|
||||
@@ -562,39 +668,45 @@ func newCacheCmd() *cobra.Command {
|
||||
cmd.AddCommand(&cobra.Command{
|
||||
Use: "clear",
|
||||
Short: "Clear the dependency cache",
|
||||
Run: cacheClearHandler,
|
||||
Run: wrapHandlerWithErrorHandling(cacheClearHandler),
|
||||
})
|
||||
|
||||
cmd.AddCommand(&cobra.Command{
|
||||
Use: "stats",
|
||||
Short: "Show cache statistics",
|
||||
Run: cacheStatsHandler,
|
||||
Run: wrapHandlerWithErrorHandling(cacheStatsHandler),
|
||||
})
|
||||
|
||||
cmd.AddCommand(&cobra.Command{
|
||||
Use: "path",
|
||||
Short: "Show cache directory path",
|
||||
Run: cachePathHandler,
|
||||
Run: wrapHandlerWithErrorHandling(cachePathHandler),
|
||||
})
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func depsListHandler(_ *cobra.Command, _ []string) {
|
||||
func depsListHandler(_ *cobra.Command, _ []string) error {
|
||||
// Ensure globalConfig is initialized
|
||||
if globalConfig == nil {
|
||||
globalConfig = internal.DefaultAppConfig()
|
||||
}
|
||||
|
||||
output := createOutputManager(globalConfig.Quiet)
|
||||
currentDir, err := helpers.GetCurrentDir()
|
||||
if err != nil {
|
||||
output.Error(appconstants.ErrErrorGettingCurrentDir, err)
|
||||
os.Exit(1)
|
||||
return wrapError(appconstants.ErrErrorGettingCurrentDir, err)
|
||||
}
|
||||
|
||||
generator := internal.NewGenerator(globalConfig)
|
||||
actionFiles, err := generator.DiscoverActionFilesWithValidation(currentDir, true, "dependency listing")
|
||||
if err != nil {
|
||||
// For deps list, we can continue if no files found (show warning instead of error)
|
||||
output.Warning(appconstants.ErrNoActionFilesFound)
|
||||
|
||||
return
|
||||
actionFiles, err := generator.DiscoverActionFilesWithValidation(
|
||||
currentDir,
|
||||
true,
|
||||
globalConfig.IgnoredDirectories,
|
||||
"dependency listing",
|
||||
)
|
||||
if err := handleNoFilesFoundError(err, output); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
analyzer := createAnalyzer(generator, output)
|
||||
@@ -603,6 +715,8 @@ func depsListHandler(_ *cobra.Command, _ []string) {
|
||||
if totalDeps > 0 {
|
||||
output.Bold("\nTotal dependencies: %d", totalDeps)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// analyzeDependencies analyzes and displays dependencies.
|
||||
@@ -659,27 +773,44 @@ func analyzeActionFileDeps(output *internal.ColoredOutput, actionFile string, an
|
||||
return len(deps)
|
||||
}
|
||||
|
||||
func depsSecurityHandler(_ *cobra.Command, _ []string) {
|
||||
output, errorHandler := setupOutputAndErrorHandling()
|
||||
func depsSecurityHandler(_ *cobra.Command, _ []string) error {
|
||||
// Ensure globalConfig is initialized
|
||||
if globalConfig == nil {
|
||||
globalConfig = internal.DefaultAppConfig()
|
||||
}
|
||||
|
||||
output := createOutputManager(globalConfig.Quiet)
|
||||
|
||||
currentDir, err := helpers.GetCurrentDir()
|
||||
if err != nil {
|
||||
errorHandler.HandleSimpleError("Failed to get current directory", err)
|
||||
return fmt.Errorf("failed to get current directory: %w", err)
|
||||
}
|
||||
|
||||
generator := internal.NewGenerator(globalConfig)
|
||||
actionFiles, err := generator.DiscoverActionFilesWithValidation(currentDir, true, "security analysis")
|
||||
actionFiles, err := generator.DiscoverActionFilesWithValidation(
|
||||
currentDir,
|
||||
true,
|
||||
globalConfig.IgnoredDirectories,
|
||||
"security analysis",
|
||||
)
|
||||
if err != nil {
|
||||
os.Exit(1)
|
||||
return fmt.Errorf(appconstants.ErrFailedToDiscoverActionFiles, err)
|
||||
}
|
||||
|
||||
analyzer := createAnalyzer(generator, output)
|
||||
if analyzer == nil {
|
||||
return
|
||||
output.Warning(
|
||||
"⚠️ Analyzer disabled: GitHub token not configured. " +
|
||||
"Use GITHUB_TOKEN or GH_README_GITHUB_TOKEN environment variable.",
|
||||
)
|
||||
|
||||
return nil // Analyzer can be nil if token isn't configured, gracefully handle
|
||||
}
|
||||
|
||||
pinnedCount, floatingDeps := analyzeSecurityDeps(output, actionFiles, analyzer)
|
||||
displaySecuritySummary(output, currentDir, pinnedCount, floatingDeps)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// analyzeSecurityDeps analyzes dependencies for security issues.
|
||||
@@ -757,34 +888,42 @@ func displayFloatingDeps(output *internal.ColoredOutput, currentDir string, floa
|
||||
}
|
||||
}
|
||||
|
||||
func depsOutdatedHandler(_ *cobra.Command, _ []string) {
|
||||
func depsOutdatedHandler(_ *cobra.Command, _ []string) error {
|
||||
// Ensure globalConfig is initialized
|
||||
if globalConfig == nil {
|
||||
globalConfig = internal.DefaultAppConfig()
|
||||
}
|
||||
|
||||
output := createOutputManager(globalConfig.Quiet)
|
||||
currentDir, err := helpers.GetCurrentDir()
|
||||
if err != nil {
|
||||
output.Error(appconstants.ErrErrorGettingCurrentDir, err)
|
||||
os.Exit(1)
|
||||
return wrapError(appconstants.ErrErrorGettingCurrentDir, err)
|
||||
}
|
||||
|
||||
generator := internal.NewGenerator(globalConfig)
|
||||
actionFiles, err := generator.DiscoverActionFilesWithValidation(currentDir, true, "outdated dependency analysis")
|
||||
if err != nil {
|
||||
// For deps outdated, we can continue if no files found (show warning instead of error)
|
||||
output.Warning(appconstants.ErrNoActionFilesFound)
|
||||
|
||||
return
|
||||
actionFiles, err := generator.DiscoverActionFilesWithValidation(
|
||||
currentDir,
|
||||
true,
|
||||
globalConfig.IgnoredDirectories,
|
||||
"outdated dependency analysis",
|
||||
)
|
||||
if err := handleNoFilesFoundError(err, output); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
analyzer := createAnalyzer(generator, output)
|
||||
if analyzer == nil {
|
||||
return
|
||||
if !validateGitHubToken(output) {
|
||||
return nil // Not an error, just no token available
|
||||
}
|
||||
|
||||
if !validateGitHubToken(output) {
|
||||
return
|
||||
if analyzer == nil {
|
||||
return nil // Analyzer can be nil if token isn't configured, gracefully handle
|
||||
}
|
||||
|
||||
allOutdated := checkAllOutdated(output, actionFiles, analyzer)
|
||||
displayOutdatedResults(output, allOutdated)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateGitHubToken checks if GitHub token is available.
|
||||
@@ -855,25 +994,30 @@ func displayOutdatedResults(output *internal.ColoredOutput, allOutdated []depend
|
||||
output.Info("\nRun 'gh-action-readme deps upgrade' to update dependencies")
|
||||
}
|
||||
|
||||
func depsUpgradeHandler(cmd *cobra.Command, _ []string) {
|
||||
func depsUpgradeHandler(cmd *cobra.Command, _ []string) error {
|
||||
// Ensure globalConfig is initialized
|
||||
if globalConfig == nil {
|
||||
globalConfig = internal.DefaultAppConfig()
|
||||
}
|
||||
|
||||
output := createOutputManager(globalConfig.Quiet)
|
||||
currentDir, err := helpers.GetCurrentDir()
|
||||
if err != nil {
|
||||
output.Error(appconstants.ErrErrorGettingCurrentDir, err)
|
||||
os.Exit(1)
|
||||
return wrapError(appconstants.ErrErrorGettingCurrentDir, err)
|
||||
}
|
||||
|
||||
// Setup and validation
|
||||
analyzer, actionFiles := setupDepsUpgrade(output, currentDir)
|
||||
if analyzer == nil || len(actionFiles) == 0 {
|
||||
return
|
||||
analyzer, actionFiles, err := setupDepsUpgrade(output, currentDir, nil)
|
||||
if err != nil {
|
||||
// setupDepsUpgrade returns descriptive errors, so just pass them through
|
||||
return err
|
||||
}
|
||||
|
||||
// Parse flags and show mode
|
||||
ciMode, _ := cmd.Flags().GetBool("ci")
|
||||
ciMode, _ := cmd.Flags().GetBool(appconstants.FlagCI)
|
||||
allFlag, _ := cmd.Flags().GetBool(appconstants.InputAll)
|
||||
dryRun, _ := cmd.Flags().GetBool(appconstants.InputDryRun)
|
||||
isPinCmd := cmd.Use == "pin"
|
||||
isPinCmd := cmd.Use == appconstants.CommandPin
|
||||
|
||||
showUpgradeMode(output, ciMode, isPinCmd)
|
||||
|
||||
@@ -882,47 +1026,57 @@ func depsUpgradeHandler(cmd *cobra.Command, _ []string) {
|
||||
if len(allUpdates) == 0 {
|
||||
output.Success("✅ No updates needed - all dependencies are current and pinned!")
|
||||
|
||||
return
|
||||
return nil
|
||||
}
|
||||
|
||||
// Show and apply updates
|
||||
showPendingUpdates(output, allUpdates, currentDir)
|
||||
if !dryRun {
|
||||
applyUpdates(output, analyzer, allUpdates, ciMode || allFlag)
|
||||
if err := applyUpdates(output, analyzer, allUpdates, ciMode || allFlag, nil); err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
output.Info("\n🔍 Dry run complete - no changes made")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// setupDepsUpgrade handles initial setup and validation for dependency upgrades.
|
||||
func setupDepsUpgrade(output *internal.ColoredOutput, currentDir string) (*dependencies.Analyzer, []string) {
|
||||
generator := internal.NewGenerator(globalConfig)
|
||||
actionFiles, err := generator.DiscoverActionFiles(currentDir, true)
|
||||
// The config parameter allows injection for testing (pass nil to use globalConfig).
|
||||
func setupDepsUpgrade(
|
||||
_ *internal.ColoredOutput,
|
||||
currentDir string,
|
||||
config *internal.AppConfig,
|
||||
) (*dependencies.Analyzer, []string, error) {
|
||||
// Default to globalConfig if not provided (backward compatible)
|
||||
if config == nil {
|
||||
if globalConfig == nil {
|
||||
globalConfig = internal.DefaultAppConfig()
|
||||
}
|
||||
config = globalConfig
|
||||
}
|
||||
|
||||
generator := internal.NewGenerator(config)
|
||||
actionFiles, err := generator.DiscoverActionFiles(currentDir, true, config.IgnoredDirectories)
|
||||
if err != nil {
|
||||
output.Error("Error discovering action files: %v", err)
|
||||
os.Exit(1)
|
||||
return nil, nil, fmt.Errorf("error discovering action files: %w", err)
|
||||
}
|
||||
|
||||
if len(actionFiles) == 0 {
|
||||
output.Warning("No action files found")
|
||||
|
||||
return nil, nil
|
||||
return nil, nil, errors.New(appconstants.ErrNoActionFilesFound)
|
||||
}
|
||||
|
||||
analyzer, err := generator.CreateDependencyAnalyzer()
|
||||
if err != nil {
|
||||
output.Warning(appconstants.ErrCouldNotCreateDependencyAnalyzer, err)
|
||||
|
||||
return nil, nil
|
||||
return nil, nil, fmt.Errorf("could not create dependency analyzer: %w", err)
|
||||
}
|
||||
|
||||
if globalConfig.GitHubToken == "" {
|
||||
output.Warning("No GitHub token found. Set GITHUB_TOKEN environment variable")
|
||||
|
||||
return nil, nil
|
||||
if config.GitHubToken == "" {
|
||||
return nil, nil, errors.New("no GitHub token found, set GITHUB_TOKEN environment variable")
|
||||
}
|
||||
|
||||
return analyzer, actionFiles
|
||||
return analyzer, actionFiles, nil
|
||||
}
|
||||
|
||||
// showUpgradeMode displays the current upgrade mode to the user.
|
||||
@@ -995,37 +1149,46 @@ func showPendingUpdates(
|
||||
}
|
||||
|
||||
// applyUpdates applies the collected updates either automatically or interactively.
|
||||
// The reader parameter allows injection of input for testing (pass nil to use stdin).
|
||||
func applyUpdates(
|
||||
output *internal.ColoredOutput,
|
||||
analyzer *dependencies.Analyzer,
|
||||
allUpdates []dependencies.PinnedUpdate,
|
||||
automatic bool,
|
||||
) {
|
||||
reader InputReader,
|
||||
) error {
|
||||
// Default to stdin if not provided
|
||||
if reader == nil {
|
||||
reader = &StdinReader{}
|
||||
}
|
||||
|
||||
if automatic {
|
||||
output.Info("\n🚀 Applying updates...")
|
||||
if err := analyzer.ApplyPinnedUpdates(allUpdates); err != nil {
|
||||
output.Error(appconstants.ErrFailedToApplyUpdates, err)
|
||||
os.Exit(1)
|
||||
return fmt.Errorf(appconstants.ErrFailedToApplyUpdatesWrapped, err)
|
||||
}
|
||||
output.Success("✅ Successfully updated %d dependencies with pinned commit SHAs", len(allUpdates))
|
||||
} else {
|
||||
// Interactive mode
|
||||
output.Info("\n❓ This will modify your action.yml files. Continue? (y/N): ")
|
||||
var response string
|
||||
_, _ = fmt.Scanln(&response) // User input, scan error not critical
|
||||
response, err := reader.ReadLine()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read response: %w", err)
|
||||
}
|
||||
if strings.ToLower(response) != "y" && strings.ToLower(response) != appconstants.InputYes {
|
||||
output.Info("Canceled")
|
||||
|
||||
return
|
||||
return nil
|
||||
}
|
||||
|
||||
output.Info("🚀 Applying updates...")
|
||||
if err := analyzer.ApplyPinnedUpdates(allUpdates); err != nil {
|
||||
output.Error(appconstants.ErrFailedToApplyUpdates, err)
|
||||
os.Exit(1)
|
||||
return fmt.Errorf(appconstants.ErrFailedToApplyUpdatesWrapped, err)
|
||||
}
|
||||
output.Success("✅ Successfully updated %d dependencies", len(allUpdates))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func depsGraphHandler(_ *cobra.Command, _ []string) {
|
||||
@@ -1035,39 +1198,48 @@ func depsGraphHandler(_ *cobra.Command, _ []string) {
|
||||
output.Printf("This feature is not yet implemented\n")
|
||||
}
|
||||
|
||||
func cacheClearHandler(_ *cobra.Command, _ []string) {
|
||||
func cacheClearHandler(_ *cobra.Command, _ []string) error {
|
||||
// Ensure globalConfig is initialized
|
||||
if globalConfig == nil {
|
||||
globalConfig = internal.DefaultAppConfig()
|
||||
}
|
||||
|
||||
output := createOutputManager(globalConfig.Quiet)
|
||||
output.Info("Clearing dependency cache...")
|
||||
|
||||
// Create a cache instance
|
||||
cacheInstance, err := cache.NewCache(cache.DefaultConfig())
|
||||
if err != nil {
|
||||
output.Error(appconstants.ErrFailedToAccessCache, err)
|
||||
os.Exit(1)
|
||||
return wrapError(appconstants.ErrFailedToAccessCache, err)
|
||||
}
|
||||
|
||||
if err := cacheInstance.Clear(); err != nil {
|
||||
output.Error("Failed to clear cache: %v", err)
|
||||
os.Exit(1)
|
||||
return fmt.Errorf("failed to clear cache: %w", err)
|
||||
}
|
||||
|
||||
output.Success("Cache cleared successfully")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func cacheStatsHandler(_ *cobra.Command, _ []string) {
|
||||
func cacheStatsHandler(_ *cobra.Command, _ []string) error {
|
||||
// Ensure globalConfig is initialized
|
||||
if globalConfig == nil {
|
||||
globalConfig = internal.DefaultAppConfig()
|
||||
}
|
||||
|
||||
output := createOutputManager(globalConfig.Quiet)
|
||||
|
||||
// Create a cache instance
|
||||
cacheInstance, err := cache.NewCache(cache.DefaultConfig())
|
||||
if err != nil {
|
||||
output.Error(appconstants.ErrFailedToAccessCache, err)
|
||||
os.Exit(1)
|
||||
return wrapError(appconstants.ErrFailedToAccessCache, err)
|
||||
}
|
||||
|
||||
stats := cacheInstance.Stats()
|
||||
|
||||
output.Bold("Cache Statistics:")
|
||||
output.Printf("Cache location: %s\n", stats["cache_dir"])
|
||||
output.Printf("Cache location: %s\n", stats[appconstants.CacheStatsKeyDir])
|
||||
output.Printf("Total entries: %d\n", stats["total_entries"])
|
||||
output.Printf("Expired entries: %d\n", stats["expired_count"])
|
||||
|
||||
@@ -1078,20 +1250,26 @@ func cacheStatsHandler(_ *cobra.Command, _ []string) {
|
||||
}
|
||||
sizeStr := formatSize(totalSize)
|
||||
output.Printf("Total size: %s\n", sizeStr)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func cachePathHandler(_ *cobra.Command, _ []string) {
|
||||
func cachePathHandler(_ *cobra.Command, _ []string) error {
|
||||
// Ensure globalConfig is initialized
|
||||
if globalConfig == nil {
|
||||
globalConfig = internal.DefaultAppConfig()
|
||||
}
|
||||
|
||||
output := createOutputManager(globalConfig.Quiet)
|
||||
|
||||
// Create a cache instance
|
||||
cacheInstance, err := cache.NewCache(cache.DefaultConfig())
|
||||
if err != nil {
|
||||
output.Error(appconstants.ErrFailedToAccessCache, err)
|
||||
os.Exit(1)
|
||||
return wrapError(appconstants.ErrFailedToAccessCache, err)
|
||||
}
|
||||
|
||||
stats := cacheInstance.Stats()
|
||||
cachePath, ok := stats["cache_dir"].(string)
|
||||
cachePath, ok := stats[appconstants.CacheStatsKeyDir].(string)
|
||||
if !ok {
|
||||
cachePath = appconstants.ScopeUnknown
|
||||
}
|
||||
@@ -1105,17 +1283,23 @@ func cachePathHandler(_ *cobra.Command, _ []string) {
|
||||
} else if os.IsNotExist(err) {
|
||||
output.Warning("Directory does not exist (will be created on first use)")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func configWizardHandler(cmd *cobra.Command, _ []string) {
|
||||
func configWizardHandler(cmd *cobra.Command, _ []string) error {
|
||||
// Ensure globalConfig is initialized
|
||||
if globalConfig == nil {
|
||||
globalConfig = internal.DefaultAppConfig()
|
||||
}
|
||||
|
||||
output := createOutputManager(globalConfig.Quiet)
|
||||
|
||||
// Create and run the wizard
|
||||
configWizard := wizard.NewConfigWizard(output)
|
||||
config, err := configWizard.Run()
|
||||
if err != nil {
|
||||
output.Error("Wizard failed: %v", err)
|
||||
os.Exit(1)
|
||||
return fmt.Errorf("wizard failed: %w", err)
|
||||
}
|
||||
|
||||
// Get export format and output path
|
||||
@@ -1130,8 +1314,7 @@ func configWizardHandler(cmd *cobra.Command, _ []string) {
|
||||
exportFormat := resolveExportFormat(format)
|
||||
defaultPath, err := exporter.GetDefaultOutputPath(exportFormat)
|
||||
if err != nil {
|
||||
output.Error("Failed to get default output path: %v", err)
|
||||
os.Exit(1)
|
||||
return fmt.Errorf("failed to get default output path: %w", err)
|
||||
}
|
||||
outputPath = defaultPath
|
||||
}
|
||||
@@ -1140,10 +1323,11 @@ func configWizardHandler(cmd *cobra.Command, _ []string) {
|
||||
exportFormat := resolveExportFormat(format)
|
||||
|
||||
if err := exporter.ExportConfig(config, exportFormat, outputPath); err != nil {
|
||||
output.Error("Failed to export configuration: %v", err)
|
||||
os.Exit(1)
|
||||
return fmt.Errorf("failed to export configuration: %w", err)
|
||||
}
|
||||
|
||||
output.Info("\n🎉 Configuration wizard completed successfully!")
|
||||
output.Info("You can now use 'gh-action-readme gen' to generate documentation.")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user