mirror of
https://github.com/ivuorinen/gh-action-readme.git
synced 2026-03-18 19:02:17 +00:00
Compare commits
20 Commits
v1.0.0
...
2de27d5cfe
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2de27d5cfe | ||
|
|
9f145dedfe | ||
|
|
78481459f5 | ||
|
|
9e25e0925f | ||
|
|
9c7be8c5d4 | ||
|
|
a75d892747 | ||
| 00044ce374 | |||
|
|
c6426bae19 | ||
|
|
7078aaba50 | ||
| 9bfecc5e6e | |||
| 6291710906 | |||
|
|
fa1ae15a4e | ||
|
|
bc021ab33d | ||
|
|
49faa8f113 | ||
|
|
0333bff9cb | ||
|
|
7ee76d0504 | ||
|
|
db19753586 | ||
|
|
2f6d19a3fc | ||
| ce23f93b74 | |||
| 9534bf9e45 |
@@ -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
|
||||
```
|
||||
|
||||
|
||||
@@ -123,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.
|
||||
@@ -150,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.
|
||||
@@ -407,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.
|
||||
@@ -547,6 +576,14 @@ const (
|
||||
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.
|
||||
@@ -634,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.
|
||||
@@ -651,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,82 +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 (
|
||||
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"
|
||||
TestDirCacheGhActionReadme = ".cache/gh-action-readme"
|
||||
)
|
||||
|
||||
// (Test file permission constants removed - use production constants from appconstants/constants.go)
|
||||
|
||||
// Test YAML content for parser tests.
|
||||
const (
|
||||
TestYAMLRoot = "name: root"
|
||||
TestYAMLNodeModules = "name: node_modules"
|
||||
TestYAMLVendor = "name: vendor"
|
||||
TestYAMLGit = "name: git"
|
||||
TestYAMLSrc = "name: src"
|
||||
TestYAMLNested = "name: nested"
|
||||
TestYAMLSub = "name: sub"
|
||||
)
|
||||
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"`
|
||||
@@ -148,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
|
||||
@@ -214,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
|
||||
@@ -231,7 +233,7 @@ func DefaultAppConfig() *AppConfig {
|
||||
|
||||
// Workflow Requirements
|
||||
Permissions: map[string]string{},
|
||||
RunsOn: []string{"ubuntu-latest"},
|
||||
RunsOn: []string{appconstants.RunnerUbuntuLatest},
|
||||
|
||||
// Features
|
||||
AnalyzeDependencies: false,
|
||||
@@ -296,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
|
||||
}
|
||||
@@ -310,20 +312,22 @@ 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.
|
||||
// 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) {
|
||||
if len(src.RunsOn) > 0 {
|
||||
dst.RunsOn = make([]string, len(src.RunsOn))
|
||||
copy(dst.RunsOn, src.RunsOn)
|
||||
}
|
||||
if len(src.IgnoredDirectories) > 0 {
|
||||
dst.IgnoredDirectories = make([]string, len(src.IgnoredDirectories))
|
||||
copy(dst.IgnoredDirectories, src.IgnoredDirectories)
|
||||
}
|
||||
copySliceIfNotEmpty(&dst.RunsOn, src.RunsOn)
|
||||
copySliceIfNotEmpty(&dst.IgnoredDirectories, src.IgnoredDirectories)
|
||||
}
|
||||
|
||||
// mergeBooleanFields merges boolean fields from src to dst if true.
|
||||
@@ -340,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.
|
||||
@@ -402,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
|
||||
@@ -423,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.ActionFileNameYML)
|
||||
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,14 +442,14 @@ 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.ActionFileNameYML)
|
||||
testutil.WriteTestFile(t, actionPath, actionContent)
|
||||
@@ -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
|
||||
@@ -552,7 +571,7 @@ func TestAnalyzer_WithoutGitHubClient(t *testing.T) {
|
||||
defer cleanup()
|
||||
|
||||
actionPath := filepath.Join(tmpDir, appconstants.ActionFileNameYML)
|
||||
testutil.WriteTestFile(t, actionPath, testutil.MustReadFixture(appconstants.TestFixtureCompositeBasic))
|
||||
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() {
|
||||
@@ -289,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()
|
||||
@@ -337,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)
|
||||
}
|
||||
@@ -351,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)
|
||||
}
|
||||
@@ -363,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.
|
||||
@@ -468,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,9 +65,139 @@ 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 {
|
||||
@@ -124,11 +264,12 @@ func DiscoverActionFiles(dir string, recursive bool, ignoredDirs []string) ([]st
|
||||
}
|
||||
|
||||
// Check only the specified directory (non-recursive)
|
||||
return discoverActionFilesNonRecursive(dir), nil
|
||||
return DiscoverActionFilesNonRecursive(dir), nil
|
||||
}
|
||||
|
||||
// discoverActionFilesNonRecursive finds action files in a single directory.
|
||||
func discoverActionFilesNonRecursive(dir string) []string {
|
||||
// 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)
|
||||
|
||||
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
|
||||
}
|
||||
@@ -3,40 +3,23 @@ package internal
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/ivuorinen/gh-action-readme/appconstants"
|
||||
"github.com/ivuorinen/gh-action-readme/testutil"
|
||||
)
|
||||
|
||||
// createTestDirWithAction creates a directory with an action.yml file and returns both paths.
|
||||
func createTestDirWithAction(t *testing.T, baseDir, dirName, yamlContent string) (string, string) {
|
||||
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()
|
||||
dirPath := filepath.Join(baseDir, dirName)
|
||||
if err := os.Mkdir(dirPath, appconstants.FilePermDir); err != nil { // nolint:gosec
|
||||
t.Fatalf(testutil.ErrCreateDir(dirName), err)
|
||||
}
|
||||
actionPath := filepath.Join(dirPath, appconstants.ActionFileNameYML)
|
||||
if err := os.WriteFile(
|
||||
actionPath, []byte(yamlContent), appconstants.FilePermDefault,
|
||||
); err != nil { // nolint:gosec
|
||||
t.Fatalf(testutil.ErrCreateFile(dirName+"/action.yml"), err)
|
||||
}
|
||||
|
||||
return dirPath, actionPath
|
||||
}
|
||||
actionPath := testutil.CreateTempActionFile(t, content)
|
||||
|
||||
// createTestFile creates a file with the given content and returns its path.
|
||||
func createTestFile(t *testing.T, baseDir, fileName, content string) string {
|
||||
t.Helper()
|
||||
filePath := filepath.Join(baseDir, fileName)
|
||||
if err := os.WriteFile(
|
||||
filePath, []byte(content), appconstants.FilePermDefault,
|
||||
); err != nil { // nolint:gosec
|
||||
t.Fatalf(testutil.ErrCreateFile(fileName), err)
|
||||
}
|
||||
|
||||
return filePath
|
||||
return ParseActionYML(actionPath)
|
||||
}
|
||||
|
||||
// validateDiscoveredFiles checks if discovered files match expected count and paths.
|
||||
@@ -83,7 +66,7 @@ func TestShouldIgnoreDirectory(t *testing.T) {
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "no match",
|
||||
name: testutil.TestCaseNameNoMatch,
|
||||
dirName: "src",
|
||||
ignoredDirs: []string{appconstants.DirNodeModules, appconstants.DirVendor},
|
||||
want: false,
|
||||
@@ -161,18 +144,18 @@ func TestDiscoverActionFilesWithIgnoredDirectories(t *testing.T) {
|
||||
// action.yml (should be found)
|
||||
|
||||
// Create root action.yml
|
||||
rootAction := createTestFile(t, tmpDir, appconstants.ActionFileNameYML, appconstants.TestYAMLRoot)
|
||||
rootAction := testutil.WriteFileInDir(t, tmpDir, appconstants.ActionFileNameYML, testutil.TestYAMLRoot)
|
||||
|
||||
// Create directories with action.yml files
|
||||
_, nodeModulesAction := createTestDirWithAction(
|
||||
_, nodeModulesAction := testutil.CreateNestedAction(
|
||||
t,
|
||||
tmpDir,
|
||||
appconstants.DirNodeModules,
|
||||
appconstants.TestYAMLNodeModules,
|
||||
testutil.TestYAMLNodeModules,
|
||||
)
|
||||
_, vendorAction := createTestDirWithAction(t, tmpDir, appconstants.DirVendor, appconstants.TestYAMLVendor)
|
||||
_, gitAction := createTestDirWithAction(t, tmpDir, appconstants.DirGit, appconstants.TestYAMLGit)
|
||||
_, srcAction := createTestDirWithAction(t, tmpDir, "src", appconstants.TestYAMLSrc)
|
||||
_, 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
|
||||
@@ -223,17 +206,9 @@ func TestDiscoverActionFilesNestedIgnoredDirs(t *testing.T) {
|
||||
// nested/
|
||||
// action.yml (should be ignored)
|
||||
|
||||
nodeModulesDir := filepath.Join(tmpDir, appconstants.DirNodeModules, "deep", "nested")
|
||||
if err := os.MkdirAll(nodeModulesDir, appconstants.FilePermDir); err != nil { // nolint:gosec
|
||||
t.Fatalf(testutil.ErrCreateDir("nested"), err)
|
||||
}
|
||||
nodeModulesDir := testutil.CreateTestSubdir(t, tmpDir, appconstants.DirNodeModules, "deep", "nested")
|
||||
|
||||
nestedAction := filepath.Join(nodeModulesDir, appconstants.ActionFileNameYML)
|
||||
if err := os.WriteFile(
|
||||
nestedAction, []byte(appconstants.TestYAMLNested), appconstants.FilePermDefault,
|
||||
); err != nil { // nolint:gosec
|
||||
t.Fatalf(testutil.ErrCreateFile("nested action.yml"), err)
|
||||
}
|
||||
testutil.WriteFileInDir(t, nodeModulesDir, appconstants.ActionFileNameYML, testutil.TestYAMLNested)
|
||||
|
||||
files, err := DiscoverActionFiles(tmpDir, true, []string{appconstants.DirNodeModules})
|
||||
if err != nil {
|
||||
@@ -251,24 +226,14 @@ func TestDiscoverActionFilesNonRecursive(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
|
||||
// Create action.yml in root
|
||||
rootAction := filepath.Join(tmpDir, appconstants.ActionFileNameYML)
|
||||
if err := os.WriteFile(
|
||||
rootAction, []byte(appconstants.TestYAMLRoot), appconstants.FilePermDefault,
|
||||
); err != nil { // nolint:gosec
|
||||
t.Fatalf(testutil.ErrCreateFile("action.yml"), err)
|
||||
}
|
||||
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 { // nolint:gosec
|
||||
if err := os.Mkdir(subDir, appconstants.FilePermDir); err != nil {
|
||||
t.Fatalf(testutil.ErrCreateDir("sub"), err)
|
||||
}
|
||||
subAction := filepath.Join(subDir, appconstants.ActionFileNameYML)
|
||||
if err := os.WriteFile(
|
||||
subAction, []byte(appconstants.TestYAMLSub), appconstants.FilePermDefault,
|
||||
); err != nil { // nolint:gosec
|
||||
t.Fatalf(testutil.ErrCreateFile("sub/action.yml"), err)
|
||||
}
|
||||
testutil.WriteFileInDir(t, subDir, appconstants.ActionFileNameYML, testutil.TestYAMLSub)
|
||||
|
||||
files, err := DiscoverActionFiles(tmpDir, false, []string{})
|
||||
if err != nil {
|
||||
@@ -283,3 +248,540 @@ func TestDiscoverActionFilesNonRecursive(t *testing.T) {
|
||||
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 {
|
||||
|
||||
@@ -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
439
main.go
439
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,10 +246,15 @@ 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")
|
||||
@@ -196,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),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -208,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 {
|
||||
@@ -219,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
|
||||
@@ -260,46 +348,46 @@ func genHandler(cmd *cobra.Command, args []string) {
|
||||
"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.
|
||||
@@ -320,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 != "." {
|
||||
@@ -345,18 +433,23 @@ 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)
|
||||
@@ -367,23 +460,17 @@ func validateHandler(_ *cobra.Command, _ []string) {
|
||||
"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) {
|
||||
@@ -417,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)")
|
||||
@@ -445,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) {
|
||||
@@ -521,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{
|
||||
@@ -546,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")
|
||||
@@ -576,30 +668,34 @@ 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)
|
||||
@@ -609,11 +705,8 @@ func depsListHandler(_ *cobra.Command, _ []string) {
|
||||
globalConfig.IgnoredDirectories,
|
||||
"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
|
||||
if err := handleNoFilesFoundError(err, output); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
analyzer := createAnalyzer(generator, output)
|
||||
@@ -622,6 +715,8 @@ func depsListHandler(_ *cobra.Command, _ []string) {
|
||||
if totalDeps > 0 {
|
||||
output.Bold("\nTotal dependencies: %d", totalDeps)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// analyzeDependencies analyzes and displays dependencies.
|
||||
@@ -678,12 +773,17 @@ 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)
|
||||
@@ -694,16 +794,23 @@ func depsSecurityHandler(_ *cobra.Command, _ []string) {
|
||||
"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.
|
||||
@@ -781,12 +888,16 @@ 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)
|
||||
@@ -796,24 +907,23 @@ func depsOutdatedHandler(_ *cobra.Command, _ []string) {
|
||||
globalConfig.IgnoredDirectories,
|
||||
"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
|
||||
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.
|
||||
@@ -884,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)
|
||||
|
||||
@@ -911,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, globalConfig.IgnoredDirectories)
|
||||
// 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.
|
||||
@@ -1024,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) {
|
||||
@@ -1064,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"])
|
||||
|
||||
@@ -1107,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
|
||||
}
|
||||
@@ -1134,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
|
||||
@@ -1159,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
|
||||
}
|
||||
@@ -1169,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
|
||||
}
|
||||
|
||||
2261
main_test.go
2261
main_test.go
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user