chore(deps): bump the github-actions-dependencies group with 6 updates
Some checks failed
Internal - Main - Continuous Integration / ci (push) Has been cancelled
Need fix to Issue / main (push) Has been cancelled
Prepare release / release (push) Has been cancelled
Internal - Main - Continuous Integration / prepare-docs (push) Has been cancelled
Internal - Main - Continuous Integration / sync-docs (push) Has been cancelled
Mark stale issues and pull requests / main (push) Has been cancelled

Bumps the github-actions-dependencies group with 6 updates:
---
updated-dependencies:
- dependency-name: hoverkraft-tech/ci-github-common/.github/workflows/linter.yml
  dependency-version: 0.36.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: github-actions-dependencies
- dependency-name: hoverkraft-tech/ci-github-common/.github/workflows/greetings.yml
  dependency-version: 0.36.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: github-actions-dependencies
- dependency-name: hoverkraft-tech/ci-github-common
  dependency-version: 0.36.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: github-actions-dependencies
- dependency-name: hoverkraft-tech/ci-github-common/.github/workflows/need-fix-to-issue.yml
  dependency-version: 0.36.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: github-actions-dependencies
- dependency-name: hoverkraft-tech/ci-github-common/.github/workflows/semantic-pull-request.yml
  dependency-version: 0.36.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: github-actions-dependencies
- dependency-name: hoverkraft-tech/ci-github-common/.github/workflows/stale.yml
  dependency-version: 0.36.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: github-actions-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
Signed-off-by: Emilien Escalle <emilien.escalle@escemi.com>
This commit is contained in:
dependabot[bot] 2026-06-04 12:57:56 +00:00 committed by Emilien Escalle
parent 98fdf9dfda
commit 9c11768144
39 changed files with 5369 additions and 12790 deletions

View File

@ -1,29 +1,30 @@
{ {
"name": "Debian", "name": "Debian",
"image": "mcr.microsoft.com/devcontainers/base:bullseye", "image": "mcr.microsoft.com/devcontainers/base:bullseye",
"features": { "features": {
"ghcr.io/devcontainers/features/node:2": {}, "ghcr.io/devcontainers/features/node:2": {},
"ghcr.io/devcontainers/features/docker-in-docker:3": {}, "ghcr.io/devcontainers/features/docker-in-docker:3": {},
"ghcr.io/devcontainers/features/github-cli:1": {} "ghcr.io/devcontainers/features/github-cli:1": {}
}, },
"remoteEnv": { "remoteEnv": {
"GITHUB_TOKEN": "${localEnv:GITHUB_TOKEN}" "GITHUB_TOKEN": "${localEnv:GITHUB_TOKEN}"
}, },
"customizations": { "customizations": {
"vscode": { "vscode": {
"extensions": [ "extensions": [
"eamodio.gitlens", "eamodio.gitlens",
"github.copilot", "github.copilot",
"github.copilot-chat", "github.copilot-chat",
"github.vscode-github-actions", "github.vscode-github-actions",
"ms-vscode.makefile-tools", "ms-vscode.makefile-tools",
"bierner.markdown-preview-github-styles", "bierner.markdown-preview-github-styles",
"dbaeumer.vscode-eslint", "esbenp.prettier-vscode",
"esbenp.prettier-vscode" "biomejs.biome",
], "vitest.explorer"
"settings": { ],
"terminal.integrated.defaultProfile.linux": "zsh" "settings": {
} "terminal.integrated.defaultProfile.linux": "zsh"
} }
} }
}
} }

View File

@ -3,6 +3,8 @@ updates:
- package-ecosystem: github-actions - package-ecosystem: github-actions
directory: / directory: /
open-pull-requests-limit: 20 open-pull-requests-limit: 20
cooldown:
default-days: 7
schedule: schedule:
interval: weekly interval: weekly
day: friday day: friday
@ -17,6 +19,8 @@ updates:
- "/test" - "/test"
- "/" - "/"
open-pull-requests-limit: 20 open-pull-requests-limit: 20
cooldown:
default-days: 7
schedule: schedule:
interval: weekly interval: weekly
day: friday day: friday
@ -29,6 +33,8 @@ updates:
- package-ecosystem: docker-compose - package-ecosystem: docker-compose
directory: "/test" directory: "/test"
open-pull-requests-limit: 20 open-pull-requests-limit: 20
cooldown:
default-days: 7
schedule: schedule:
interval: weekly interval: weekly
day: friday day: friday
@ -42,6 +48,8 @@ updates:
directory: "/" directory: "/"
open-pull-requests-limit: 20 open-pull-requests-limit: 20
versioning-strategy: increase versioning-strategy: increase
cooldown:
default-days: 7
schedule: schedule:
interval: weekly interval: weekly
day: friday day: friday
@ -62,6 +70,8 @@ updates:
- package-ecosystem: "devcontainers" - package-ecosystem: "devcontainers"
open-pull-requests-limit: 20 open-pull-requests-limit: 20
directory: "/" directory: "/"
cooldown:
default-days: 7
schedule: schedule:
interval: weekly interval: weekly
day: friday day: friday

View File

@ -1,3 +1,3 @@
{ {
"ignore": ["**/dist/**", "**/node_modules/**", "**/coverage/**"] "ignore": ["**/dist/**", "**/node_modules/**", "**/coverage/**"]
} }

View File

@ -7,7 +7,7 @@ permissions: {}
jobs: jobs:
linter: linter:
uses: hoverkraft-tech/ci-github-common/.github/workflows/linter.yml@66578f5b9aec4ac5558b5dad750c4c74dfcb65c5 # 0.35.5 uses: hoverkraft-tech/ci-github-common/.github/workflows/linter.yml@4bb7594b1bf3696c54b2bbae970376056853f8ea # 0.36.0
permissions: permissions:
actions: read actions: read
contents: read contents: read
@ -15,7 +15,7 @@ jobs:
statuses: write statuses: write
with: with:
linter-env: | linter-env: |
FILTER_REGEX_EXCLUDE=dist/**/* FILTER_REGEX_EXCLUDE=dist/**/*|.github/social-preview.svg|.github/logo.svg
VALIDATE_JSCPD=false VALIDATE_JSCPD=false
VALIDATE_TYPESCRIPT_STANDARD=false VALIDATE_TYPESCRIPT_STANDARD=false
VALIDATE_TYPESCRIPT_ES=false VALIDATE_TYPESCRIPT_ES=false
@ -33,7 +33,6 @@ jobs:
packages: read packages: read
pull-requests: write pull-requests: write
security-events: write security-events: write
secrets: inherit
check-dist: check-dist:
name: Test nodejs name: Test nodejs

View File

@ -3,14 +3,14 @@ name: Greetings
on: on:
issues: issues:
types: [opened] types: [opened]
pull_request_target: pull_request_target: # zizmor: ignore[dangerous-triggers] metadata-only reusable workflow with explicit write permissions and no checkout
branches: [main] branches: [main]
permissions: {} permissions: {}
jobs: jobs:
greetings: greetings:
uses: hoverkraft-tech/ci-github-common/.github/workflows/greetings.yml@66578f5b9aec4ac5558b5dad750c4c74dfcb65c5 # 0.35.5 uses: hoverkraft-tech/ci-github-common/.github/workflows/greetings.yml@4bb7594b1bf3696c54b2bbae970376056853f8ea # 0.36.0
permissions: permissions:
contents: read contents: read
issues: write issues: write

View File

@ -27,7 +27,6 @@ jobs:
pull-requests: write pull-requests: write
security-events: write security-events: write
statuses: write statuses: write
secrets: inherit
prepare-docs: prepare-docs:
needs: ci needs: ci
@ -65,9 +64,9 @@ jobs:
id: generate-token id: generate-token
with: with:
client-id: ${{ vars.CI_BOT_APP_CLIENT_ID }} client-id: ${{ vars.CI_BOT_APP_CLIENT_ID }}
private-key: ${{ secrets.CI_BOT_APP_PRIVATE_KEY }} private-key: ${{ secrets.CI_BOT_APP_PRIVATE_KEY }} # zizmor: ignore[secrets-outside-env] repository automation uses a dedicated app secret without untrusted code execution
- uses: hoverkraft-tech/ci-github-common/actions/create-and-merge-pull-request@66578f5b9aec4ac5558b5dad750c4c74dfcb65c5 # 0.35.5 - uses: hoverkraft-tech/ci-github-common/actions/create-and-merge-pull-request@4bb7594b1bf3696c54b2bbae970376056853f8ea # 0.36.0
with: with:
github-token: ${{ steps.generate-token.outputs.token }} github-token: ${{ steps.generate-token.outputs.token }}
branch: docs/actions-workflows-documentation-update branch: docs/actions-workflows-documentation-update

View File

@ -11,15 +11,16 @@ on:
description: "The SHA of the commit to get the diff for" description: "The SHA of the commit to get the diff for"
required: true required: true
manual-base-ref: manual-base-ref:
description: "By default, the commit entered above is compared to the one directly description: >-
before it; to go back further, enter an earlier SHA here" By default, the commit entered above is compared to the one directly
before it; to go back further, enter an earlier SHA here
required: false required: false
permissions: {} permissions: {}
jobs: jobs:
main: main:
uses: hoverkraft-tech/ci-github-common/.github/workflows/need-fix-to-issue.yml@66578f5b9aec4ac5558b5dad750c4c74dfcb65c5 # 0.35.5 uses: hoverkraft-tech/ci-github-common/.github/workflows/need-fix-to-issue.yml@4bb7594b1bf3696c54b2bbae970376056853f8ea # 0.36.0
permissions: permissions:
contents: read contents: read
issues: write issues: write

View File

@ -22,4 +22,3 @@ jobs:
pull-requests: write pull-requests: write
security-events: write security-events: write
statuses: write statuses: write
secrets: inherit

View File

@ -1,7 +1,7 @@
name: "Pull Request - Semantic Lint" name: "Pull Request - Semantic Lint"
on: on:
pull_request_target: pull_request_target: # zizmor: ignore[dangerous-triggers] validates PR metadata only through a pinned reusable workflow without checkout
types: types:
- opened - opened
- edited - edited
@ -11,7 +11,7 @@ permissions: {}
jobs: jobs:
main: main:
uses: hoverkraft-tech/ci-github-common/.github/workflows/semantic-pull-request.yml@66578f5b9aec4ac5558b5dad750c4c74dfcb65c5 # 0.35.5 uses: hoverkraft-tech/ci-github-common/.github/workflows/semantic-pull-request.yml@4bb7594b1bf3696c54b2bbae970376056853f8ea # 0.36.0
permissions: permissions:
contents: write contents: write
pull-requests: write pull-requests: write

View File

@ -8,7 +8,7 @@ permissions: {}
jobs: jobs:
main: main:
uses: hoverkraft-tech/ci-github-common/.github/workflows/stale.yml@66578f5b9aec4ac5558b5dad750c4c74dfcb65c5 # 0.35.5 uses: hoverkraft-tech/ci-github-common/.github/workflows/stale.yml@4bb7594b1bf3696c54b2bbae970376056853f8ea # 0.36.0
permissions: permissions:
issues: write issues: write
pull-requests: write pull-requests: write

7
.gitignore vendored
View File

@ -12,7 +12,6 @@ lerna-debug.log*
# Diagnostic reports (https://nodejs.org/api/report.html) # Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
eslint-report.json
# Runtime data # Runtime data
pids pids
@ -54,9 +53,6 @@ typings/
# Optional npm cache directory # Optional npm cache directory
.npm .npm
# Optional eslint cache
.eslintcache
# Optional REPL history # Optional REPL history
.node_repl_history .node_repl_history
@ -102,3 +98,6 @@ __tests__/runner/*
.idea .idea
.vscode .vscode
*.code-workspace *.code-workspace
lint.sarif
biome-report.sarif
junit.xml

View File

@ -1,12 +1,5 @@
FROM ghcr.io/super-linter/super-linter:slim-v8.0.0 FROM ghcr.io/hoverkraft-tech/docker-base-images/super-linter:0.6.0
HEALTHCHECK --interval=5m --timeout=10s --start-period=30s --retries=3 CMD ["/bin/sh","-c","test -d /github/home"] HEALTHCHECK --interval=5m --timeout=10s --start-period=30s --retries=3 CMD ["/bin/sh","-c","test -d /github/home"]
ARG UID=1000 ARG UID=1000
ARG GID=1000 ARG GID=1000
RUN chown -R ${UID}:${GID} /github/home
USER ${UID}:${GID} USER ${UID}:${GID}
ENV RUN_LOCAL=true
ENV USE_FIND_ALGORITHM=true
ENV LOG_LEVEL=WARN
ENV LOG_FILE="/github/home/logs"

View File

@ -7,13 +7,13 @@ lint: ## Execute linting
$(call run_linter,) $(call run_linter,)
lint-fix: ## Execute linting and fix lint-fix: ## Execute linting and fix
@npm run format
$(call run_linter, \ $(call run_linter, \
-e FIX_JSON_PRETTIER=true \
-e FIX_JAVASCRIPT_PRETTIER=true \
-e FIX_YAML_PRETTIER=true \
-e FIX_MARKDOWN=true \ -e FIX_MARKDOWN=true \
-e FIX_MARKDOWN_PRETTIER=true \
-e FIX_NATURAL_LANGUAGE=true \ -e FIX_NATURAL_LANGUAGE=true \
-e FIX_SHELL_SHFMT=true \
-e FIX_BIOME_LINT=true \
-e FIX_BIOME_FORMAT=true \
) )
ci: ## Execute all formats and checks ci: ## Execute all formats and checks
@ -29,12 +29,8 @@ define run_linter
docker build --build-arg UID=$(shell id -u) --build-arg GID=$(shell id -g) --tag $$LINTER_IMAGE .; \ docker build --build-arg UID=$(shell id -u) --build-arg GID=$(shell id -g) --tag $$LINTER_IMAGE .; \
docker run \ docker run \
-e DEFAULT_WORKSPACE="$$DEFAULT_WORKSPACE" \ -e DEFAULT_WORKSPACE="$$DEFAULT_WORKSPACE" \
-e FILTER_REGEX_EXCLUDE="dist/**/*|.github/social-preview.svg|.github/logo.svg" \
-e FILTER_REGEX_INCLUDE="$(filter-out $@,$(MAKECMDGOALS))" \ -e FILTER_REGEX_INCLUDE="$(filter-out $@,$(MAKECMDGOALS))" \
-e IGNORE_GITIGNORED_FILES=true \
-e FILTER_REGEX_EXCLUDE=dist/**/* \
-e VALIDATE_TYPESCRIPT_ES=false \
-e VALIDATE_TYPESCRIPT_PRETTIER=false \
-e VALIDATE_JAVASCRIPT_ES=false \
$(1) \ $(1) \
-v $$VOLUME \ -v $$VOLUME \
--rm \ --rm \

34
biome.json Normal file
View File

@ -0,0 +1,34 @@
{
"$schema": "https://biomejs.dev/schemas/2.4.16/schema.json",
"vcs": {
"enabled": true,
"clientKind": "git",
"useIgnoreFile": true
},
"files": {
"includes": ["**", "!!**/dist"]
},
"formatter": {
"enabled": true,
"indentStyle": "tab"
},
"linter": {
"enabled": true,
"rules": {
"recommended": true
}
},
"javascript": {
"formatter": {
"quoteStyle": "double"
}
},
"assist": {
"enabled": true,
"actions": {
"source": {
"organizeImports": "on"
}
}
}
}

23
dist/index.js generated vendored
View File

@ -43860,8 +43860,11 @@ class InputService {
}) || null); }) || null);
} }
getServiceLogLevel() { getServiceLogLevel() {
const configuredLevel = getInput(InputNames.ServiceLogLevel, { required: false }); const configuredLevel = getInput(InputNames.ServiceLogLevel, {
if (configuredLevel && !Object.values(LogLevel).includes(configuredLevel)) { required: false,
});
if (configuredLevel &&
!Object.values(LogLevel).includes(configuredLevel)) {
throw new Error(`Invalid service log level "${configuredLevel}". Valid values are: ${Object.values(LogLevel).join(", ")}`); throw new Error(`Invalid service log level "${configuredLevel}". Valid values are: ${Object.values(LogLevel).join(", ")}`);
} }
return configuredLevel || LogLevel.Debug; return configuredLevel || LogLevel.Debug;
@ -43943,12 +43946,12 @@ class DockerComposeService {
parts.push("Docker Compose command failed"); parts.push("Docker Compose command failed");
} }
// Add error stream output if available // Add error stream output if available
if (error.err && error.err.trim()) { if (error.err?.trim()) {
parts.push("\nError output:"); parts.push("\nError output:");
parts.push(error.err.trim()); parts.push(error.err.trim());
} }
// Add standard output if available and different from error output // Add standard output if available and different from error output
if (error.out && error.out.trim() && error.out !== error.err) { if (error.out?.trim() && error.out !== error.err) {
parts.push("\nStandard output:"); parts.push("\nStandard output:");
parts.push(error.out.trim()); parts.push(error.out.trim());
} }
@ -48237,14 +48240,17 @@ class DockerComposeInstallerService {
constructor(manualInstallerAdapter) { constructor(manualInstallerAdapter) {
this.manualInstallerAdapter = manualInstallerAdapter; this.manualInstallerAdapter = manualInstallerAdapter;
} }
async install({ composeVersion, cwd, githubToken }) { async install({ composeVersion, cwd, githubToken, }) {
const currentVersion = await this.version({ cwd }); const currentVersion = await this.version({ cwd });
const normalizedCurrentVersion = currentVersion ? this.normalizeVersion(currentVersion) : null; const normalizedCurrentVersion = currentVersion
? this.normalizeVersion(currentVersion)
: null;
const normalizedRequestedVersion = composeVersion const normalizedRequestedVersion = composeVersion
? this.normalizeVersion(composeVersion) ? this.normalizeVersion(composeVersion)
: null; : null;
const needsInstall = !currentVersion || const needsInstall = !currentVersion ||
(composeVersion && normalizedRequestedVersion !== normalizedCurrentVersion); (composeVersion &&
normalizedRequestedVersion !== normalizedCurrentVersion);
if (!needsInstall) { if (!needsInstall) {
return currentVersion; return currentVersion;
} }
@ -48258,7 +48264,8 @@ class DockerComposeInstallerService {
await this.installVersion(targetVersion); await this.installVersion(targetVersion);
const installedVersion = await this.version({ cwd }); const installedVersion = await this.version({ cwd });
if (!installedVersion || if (!installedVersion ||
this.normalizeVersion(installedVersion) !== this.normalizeVersion(targetVersion)) { this.normalizeVersion(installedVersion) !==
this.normalizeVersion(targetVersion)) {
throw new Error(`Failed to install Docker Compose version "${targetVersion}", installed version is "${installedVersion ?? "unknown"}"`); throw new Error(`Failed to install Docker Compose version "${targetVersion}", installed version is "${installedVersion ?? "unknown"}"`);
} }
return installedVersion; return installedVersion;

11
dist/post.js generated vendored
View File

@ -40112,8 +40112,11 @@ class InputService {
}) || null); }) || null);
} }
getServiceLogLevel() { getServiceLogLevel() {
const configuredLevel = getInput(InputNames.ServiceLogLevel, { required: false }); const configuredLevel = getInput(InputNames.ServiceLogLevel, {
if (configuredLevel && !Object.values(LogLevel).includes(configuredLevel)) { required: false,
});
if (configuredLevel &&
!Object.values(LogLevel).includes(configuredLevel)) {
throw new Error(`Invalid service log level "${configuredLevel}". Valid values are: ${Object.values(LogLevel).join(", ")}`); throw new Error(`Invalid service log level "${configuredLevel}". Valid values are: ${Object.values(LogLevel).join(", ")}`);
} }
return configuredLevel || LogLevel.Debug; return configuredLevel || LogLevel.Debug;
@ -40195,12 +40198,12 @@ class DockerComposeService {
parts.push("Docker Compose command failed"); parts.push("Docker Compose command failed");
} }
// Add error stream output if available // Add error stream output if available
if (error.err && error.err.trim()) { if (error.err?.trim()) {
parts.push("\nError output:"); parts.push("\nError output:");
parts.push(error.err.trim()); parts.push(error.err.trim());
} }
// Add standard output if available and different from error output // Add standard output if available and different from error output
if (error.out && error.out.trim() && error.out !== error.err) { if (error.out?.trim() && error.out !== error.err) {
parts.push("\nStandard output:"); parts.push("\nStandard output:");
parts.push(error.out.trim()); parts.push(error.out.trim());
} }

View File

@ -1,3 +0,0 @@
import tsDevToolsCore from "@ts-dev-tools/core/dist/eslint-plugin-ts-dev-tools/index.js";
export default tsDevToolsCore.default;

13673
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,126 +1,64 @@
{ {
"name": "compose-action", "name": "compose-action",
"description": "Docker Compose Action", "description": "Docker Compose Action",
"version": "0.0.0", "version": "0.0.0",
"author": "hoverkraft", "author": "hoverkraft",
"license": "MIT", "license": "MIT",
"homepage": "https://github.com/hoverkraft-tech/compose-action", "homepage": "https://github.com/hoverkraft-tech/compose-action",
"private": true, "private": true,
"type": "module", "type": "module",
"repository": { "repository": {
"type": "git", "type": "git",
"url": "git+https://github.com/hoverkraft-tech/compose-action.git" "url": "git+https://github.com/hoverkraft-tech/compose-action.git"
}, },
"bugs": { "bugs": {
"url": "https://github.com/hoverkraft-tech/compose-action/issues" "url": "https://github.com/hoverkraft-tech/compose-action/issues"
}, },
"keywords": [ "keywords": [
"actions", "actions",
"docker-compose" "docker-compose"
], ],
"exports": { "exports": {
".": "./dist/index.js" ".": "./dist/index.js"
}, },
"engines": { "engines": {
"node": ">=20" "node": ">=20"
}, },
"dependencies": { "dependencies": {
"@actions/core": "^3.0.1", "@actions/core": "^3.0.1",
"@actions/github": "^9.1.1", "@actions/github": "^9.1.1",
"@actions/tool-cache": "^4.0.0", "@actions/tool-cache": "^4.0.0",
"@octokit/action": "^8.0.4", "@octokit/action": "^8.0.4",
"docker-compose": "^1.4.2" "docker-compose": "^1.4.2"
}, },
"devDependencies": { "devDependencies": {
"@ts-dev-tools/core": "^1.12.0", "@ts-dev-tools/core": "^1.12.4",
"@vercel/ncc": "^0.38.4", "@vercel/ncc": "^0.38.4"
"eslint-plugin-github": "^6.0.0", },
"eslint-plugin-jsonc": "^3.1.2" "scripts": {
}, "package": "npm run package:index && npm run package:post",
"scripts": { "package:index": "ncc build src/index.ts -o dist --license licenses.txt",
"package": "npm run package:index && npm run package:post", "package:post": "ncc build src/post.ts -o dist/post && mv dist/post/index.js dist/post.js && rm -rf dist/post",
"package:index": "ncc build src/index.ts -o dist --license licenses.txt", "package:watch": "npm run package -- --watch",
"package:post": "ncc build src/post.ts -o dist/post && mv dist/post/index.js dist/post.js && rm -rf dist/post", "lint": "biome lint --error-on-warnings .",
"package:watch": "npm run package -- --watch", "lint:ci": "biome lint --error-on-warnings . --reporter=sarif | tee biome-report.sarif",
"lint": "eslint \"src/**/*.{ts,tsx}\"", "all": "npm run format && npm run lint:ci && npm run test:ci && npm run package",
"lint:ci": "npm run lint -- --output-file eslint-report.json --format json", "build": "tsc --noEmit",
"all": "npm run format && npm run lint:ci && npm run test:ci && npm run package", "format": "biome format --write .",
"build": "tsc --noEmit", "test": "vitest run",
"format": "prettier --cache --write .", "test:watch": "vitest",
"test": "node --experimental-vm-modules node_modules/jest/bin/jest.js --detectOpenHandles --forceExit --maxWorkers=50%", "test:cov": "vitest run --reporter=default --reporter=junit --outputFile=junit.xml --coverage.enabled --coverage.reporter=lcov --coverage.reporter=text",
"test:watch": "node --experimental-vm-modules node_modules/jest/bin/jest.js --watch --maxWorkers=25%", "test:ci": "npm run test:cov",
"test:cov": "npm run test -- --coverage", "prepare": "ts-dev-tools install",
"test:ci": "node --experimental-vm-modules node_modules/jest/bin/jest.js --coverage --runInBand --detectOpenHandles --forceExit", "check": "biome check --error-on-warnings --write .",
"prepare": "ts-dev-tools install" "vitest": "vitest"
}, },
"jest": { "commitlint": {
"preset": "ts-jest/presets/default-esm", "extends": [
"verbose": true, "@commitlint/config-conventional"
"clearMocks": true, ]
"testEnvironment": "node", },
"extensionsToTreatAsEsm": [ "tsDevTools": {
".ts" "version": "20260604100000-migrate-to-vitest"
], }
"moduleFileExtensions": [
"js",
"ts"
],
"testMatch": [
"**/*.test.ts",
"**/__tests__/**/*.[jt]s?(x)",
"**/?(*.)+(spec|test)?(.*).+(ts|tsx|js)"
],
"testPathIgnorePatterns": [
"/node_modules/",
"/dist/"
],
"moduleNameMapper": {
"^(\\.{1,2}/.*)\\.js$": "$1"
},
"transform": {
"^.+\\.ts$": [
"ts-jest",
{
"useESM": true,
"tsconfig": "tsconfig.json"
}
]
},
"injectGlobals": true,
"coverageReporters": [
"json-summary",
"text",
"lcov"
],
"collectCoverage": true,
"collectCoverageFrom": [
"./src/**",
"**/src/**/*.[jt]s?(x)"
]
},
"prettier": {
"semi": true,
"printWidth": 100,
"trailingComma": "es5",
"plugins": []
},
"commitlint": {
"extends": [
"@commitlint/config-conventional"
]
},
"lint-staged": {
"*.{js,ts,tsx}": [
"eslint --fix"
]
},
"importSort": {
".js, .jsx, .ts, .tsx": {
"style": "module",
"parser": "typescript"
}
},
"tsDevTools": {
"version": "20250623095600-remove-prettier-oxc"
}
} }

View File

@ -1,193 +1,204 @@
import { jest, describe, it, expect, beforeEach } from "@jest/globals"; import { describe, expect, it, beforeEach, vi } from "vitest";
// Mock @actions/core // Mock @actions/core
const setFailedMock = jest.fn(); const setFailedMock = vi.fn();
jest.unstable_mockModule("@actions/core", () => ({ vi.doMock("@actions/core", () => ({
setFailed: setFailedMock, setFailed: setFailedMock,
getInput: jest.fn().mockReturnValue(""), getInput: vi.fn().mockReturnValue(""),
getMultilineInput: jest.fn().mockReturnValue([]), getMultilineInput: vi.fn().mockReturnValue([]),
debug: jest.fn(), debug: vi.fn(),
info: jest.fn(), info: vi.fn(),
warning: jest.fn(), warning: vi.fn(),
})); }));
// Mock docker-compose // Mock docker-compose
jest.unstable_mockModule("docker-compose", () => ({ vi.doMock("docker-compose", () => ({
upAll: jest.fn(), upAll: vi.fn(),
upMany: jest.fn(), upMany: vi.fn(),
down: jest.fn(), down: vi.fn(),
logs: jest.fn(), logs: vi.fn(),
version: jest version: vi
.fn<() => Promise<{ data: { version: string } }>>() .fn<() => Promise<{ data: { version: string } }>>()
.mockResolvedValue({ data: { version: "1.2.3" } }), .mockResolvedValue({ data: { version: "1.2.3" } }),
})); }));
// Mock node:fs // Mock node:fs
jest.unstable_mockModule("node:fs", async () => { vi.doMock("node:fs", async () => {
const actualFs = await jest.requireActual<typeof import("node:fs")>("node:fs"); const actualFs = await vi.importActual<typeof import("node:fs")>("node:fs");
return { return {
...actualFs, ...actualFs,
existsSync: jest.fn().mockReturnValue(true), existsSync: vi.fn().mockReturnValue(true),
default: { default: {
...actualFs, ...actualFs,
existsSync: jest.fn().mockReturnValue(true), existsSync: vi.fn().mockReturnValue(true),
}, },
}; };
}); });
// Dynamic imports after mock setup // Dynamic imports after mock setup
const { run } = await import("./index-runner.js"); const { run } = await import("./index-runner.js");
const { InputService } = await import("./services/input.service.js"); const { InputService } = await import("./services/input.service.js");
const { LoggerService, LogLevel } = await import("./services/logger.service.js"); const { LoggerService, LogLevel } = await import(
const { DockerComposeInstallerService } = "./services/logger.service.js"
await import("./services/docker-compose-installer.service.js"); );
const { DockerComposeService } = await import("./services/docker-compose.service.js"); const { DockerComposeInstallerService } = await import(
"./services/docker-compose-installer.service.js"
);
const { DockerComposeService } = await import(
"./services/docker-compose.service.js"
);
describe("run", () => { describe("run", () => {
let infoMock: jest.SpiedFunction<typeof LoggerService.prototype.info>; let infoMock: ReturnType<typeof vi.spyOn>;
let debugMock: jest.SpiedFunction<typeof LoggerService.prototype.debug>; let debugMock: ReturnType<typeof vi.spyOn>;
let getInputsMock: jest.SpiedFunction<typeof InputService.prototype.getInputs>; let getInputsMock: ReturnType<typeof vi.spyOn>;
let installMock: jest.SpiedFunction<typeof DockerComposeInstallerService.prototype.install>; let installMock: ReturnType<typeof vi.spyOn>;
let upMock: jest.SpiedFunction<typeof DockerComposeService.prototype.up>; let upMock: ReturnType<typeof vi.spyOn>;
beforeEach(() => { beforeEach(() => {
jest.clearAllMocks(); vi.clearAllMocks();
infoMock = jest.spyOn(LoggerService.prototype, "info").mockImplementation(() => {}); infoMock = vi
debugMock = jest.spyOn(LoggerService.prototype, "debug").mockImplementation(() => {}); .spyOn(LoggerService.prototype, "info")
getInputsMock = jest.spyOn(InputService.prototype, "getInputs"); .mockImplementation(() => {});
installMock = jest.spyOn(DockerComposeInstallerService.prototype, "install"); debugMock = vi
upMock = jest.spyOn(DockerComposeService.prototype, "up"); .spyOn(LoggerService.prototype, "debug")
}); .mockImplementation(() => {});
getInputsMock = vi.spyOn(InputService.prototype, "getInputs");
installMock = vi.spyOn(DockerComposeInstallerService.prototype, "install");
upMock = vi.spyOn(DockerComposeService.prototype, "up");
});
it("should install docker compose with specified version", async () => { it("should install docker compose with specified version", async () => {
// Arrange // Arrange
getInputsMock.mockImplementation(() => ({ getInputsMock.mockImplementation(() => ({
dockerFlags: [], dockerFlags: [],
composeFiles: ["docker-compose.yml"], composeFiles: ["docker-compose.yml"],
services: [], services: [],
composeFlags: [], composeFlags: [],
upFlags: [], upFlags: [],
downFlags: [], downFlags: [],
cwd: "/current/working/dir", cwd: "/current/working/dir",
composeVersion: "1.29.2", composeVersion: "1.29.2",
githubToken: null, githubToken: null,
serviceLogLevel: LogLevel.Debug, serviceLogLevel: LogLevel.Debug,
})); }));
installMock.mockResolvedValue("1.29.2"); installMock.mockResolvedValue("1.29.2");
upMock.mockResolvedValue(); upMock.mockResolvedValue();
// Act // Act
await run(); await run();
// Assert // Assert
expect(infoMock).toHaveBeenCalledWith("Setting up docker compose version 1.29.2"); expect(infoMock).toHaveBeenCalledWith(
"Setting up docker compose version 1.29.2",
);
expect(debugMock).toHaveBeenCalledWith( expect(debugMock).toHaveBeenCalledWith(
'inputs: {"dockerFlags":[],"composeFiles":["docker-compose.yml"],"services":[],"composeFlags":[],"upFlags":[],"downFlags":[],"cwd":"/current/working/dir","composeVersion":"1.29.2","githubToken":null,"serviceLogLevel":"debug"}' 'inputs: {"dockerFlags":[],"composeFiles":["docker-compose.yml"],"services":[],"composeFlags":[],"upFlags":[],"downFlags":[],"cwd":"/current/working/dir","composeVersion":"1.29.2","githubToken":null,"serviceLogLevel":"debug"}',
); );
expect(installMock).toHaveBeenCalledWith({ expect(installMock).toHaveBeenCalledWith({
composeVersion: "1.29.2", composeVersion: "1.29.2",
cwd: "/current/working/dir", cwd: "/current/working/dir",
githubToken: null, githubToken: null,
}); });
expect(upMock).toHaveBeenCalledWith({ expect(upMock).toHaveBeenCalledWith({
dockerFlags: [], dockerFlags: [],
composeFiles: ["docker-compose.yml"], composeFiles: ["docker-compose.yml"],
composeFlags: [], composeFlags: [],
cwd: "/current/working/dir", cwd: "/current/working/dir",
upFlags: [], upFlags: [],
services: [], services: [],
serviceLogger: debugMock, serviceLogger: debugMock,
}); });
expect(setFailedMock).not.toHaveBeenCalled(); expect(setFailedMock).not.toHaveBeenCalled();
}); });
it("should bring up docker compose services", async () => { it("should bring up docker compose services", async () => {
// Arrange // Arrange
getInputsMock.mockImplementation(() => ({ getInputsMock.mockImplementation(() => ({
dockerFlags: [], dockerFlags: [],
composeFiles: ["docker-compose.yml"], composeFiles: ["docker-compose.yml"],
services: ["web"], services: ["web"],
composeFlags: [], composeFlags: [],
upFlags: [], upFlags: [],
downFlags: [], downFlags: [],
cwd: "/current/working/dir", cwd: "/current/working/dir",
composeVersion: null, composeVersion: null,
githubToken: null, githubToken: null,
serviceLogLevel: LogLevel.Debug, serviceLogLevel: LogLevel.Debug,
})); }));
// Act // Act
await run(); await run();
// Assert // Assert
expect(upMock).toHaveBeenCalledWith({ expect(upMock).toHaveBeenCalledWith({
dockerFlags: [], dockerFlags: [],
composeFiles: ["docker-compose.yml"], composeFiles: ["docker-compose.yml"],
composeFlags: [], composeFlags: [],
cwd: "/current/working/dir", cwd: "/current/working/dir",
upFlags: [], upFlags: [],
services: ["web"], services: ["web"],
serviceLogger: debugMock, serviceLogger: debugMock,
}); });
expect(setFailedMock).not.toHaveBeenCalled(); expect(setFailedMock).not.toHaveBeenCalled();
}); });
it("should handle errors and call setFailed", async () => { it("should handle errors and call setFailed", async () => {
// Arrange // Arrange
const error = new Error("Test error"); const error = new Error("Test error");
upMock.mockRejectedValue(error); upMock.mockRejectedValue(error);
getInputsMock.mockImplementation(() => ({ getInputsMock.mockImplementation(() => ({
dockerFlags: [], dockerFlags: [],
composeFiles: ["docker-compose.yml"], composeFiles: ["docker-compose.yml"],
services: ["web"], services: ["web"],
composeFlags: [], composeFlags: [],
upFlags: [], upFlags: [],
downFlags: [], downFlags: [],
cwd: "/current/working/dir", cwd: "/current/working/dir",
composeVersion: null, composeVersion: null,
githubToken: null, githubToken: null,
serviceLogLevel: LogLevel.Debug, serviceLogLevel: LogLevel.Debug,
})); }));
// Act // Act
await run(); await run();
// Assert // Assert
expect(setFailedMock).toHaveBeenCalledWith("Error: Test error"); expect(setFailedMock).toHaveBeenCalledWith("Error: Test error");
}); });
it("should handle unknown errors and call setFailed", async () => { it("should handle unknown errors and call setFailed", async () => {
// Arrange // Arrange
const error = "Test error"; const error = "Test error";
upMock.mockRejectedValue(error); upMock.mockRejectedValue(error);
getInputsMock.mockImplementation(() => ({ getInputsMock.mockImplementation(() => ({
dockerFlags: [], dockerFlags: [],
composeFiles: ["docker-compose.yml"], composeFiles: ["docker-compose.yml"],
services: ["web"], services: ["web"],
composeFlags: [], composeFlags: [],
upFlags: [], upFlags: [],
downFlags: [], downFlags: [],
cwd: "/current/working/dir", cwd: "/current/working/dir",
composeVersion: null, composeVersion: null,
githubToken: null, githubToken: null,
serviceLogLevel: LogLevel.Debug, serviceLogLevel: LogLevel.Debug,
})); }));
// Act // Act
await run(); await run();
// Assert // Assert
expect(setFailedMock).toHaveBeenCalledWith('"Test error"'); expect(setFailedMock).toHaveBeenCalledWith('"Test error"');
}); });
}); });

View File

@ -10,42 +10,42 @@ import { ManualInstallerAdapter } from "./services/installer-adapter/manual-inst
* @returns {Promise<void>} Resolves when the action is complete. * @returns {Promise<void>} Resolves when the action is complete.
*/ */
export async function run(): Promise<void> { export async function run(): Promise<void> {
try { try {
const loggerService = new LoggerService(); const loggerService = new LoggerService();
const inputService = new InputService(); const inputService = new InputService();
const dockerComposeInstallerService = new DockerComposeInstallerService( const dockerComposeInstallerService = new DockerComposeInstallerService(
new ManualInstallerAdapter() new ManualInstallerAdapter(),
); );
const dockerComposeService = new DockerComposeService(); const dockerComposeService = new DockerComposeService();
const inputs = inputService.getInputs(); const inputs = inputService.getInputs();
loggerService.debug(`inputs: ${JSON.stringify(inputs)}`); loggerService.debug(`inputs: ${JSON.stringify(inputs)}`);
loggerService.info( loggerService.info(
"Setting up docker compose" + "Setting up docker compose" +
(inputs.composeVersion ? ` version ${inputs.composeVersion}` : "") (inputs.composeVersion ? ` version ${inputs.composeVersion}` : ""),
); );
const installedVersion = await dockerComposeInstallerService.install({ const installedVersion = await dockerComposeInstallerService.install({
composeVersion: inputs.composeVersion, composeVersion: inputs.composeVersion,
cwd: inputs.cwd, cwd: inputs.cwd,
githubToken: inputs.githubToken, githubToken: inputs.githubToken,
}); });
loggerService.info(`docker compose version: ${installedVersion}`); loggerService.info(`docker compose version: ${installedVersion}`);
loggerService.info("Bringing up docker compose service(s)"); loggerService.info("Bringing up docker compose service(s)");
await dockerComposeService.up({ await dockerComposeService.up({
dockerFlags: inputs.dockerFlags, dockerFlags: inputs.dockerFlags,
composeFiles: inputs.composeFiles, composeFiles: inputs.composeFiles,
composeFlags: inputs.composeFlags, composeFlags: inputs.composeFlags,
cwd: inputs.cwd, cwd: inputs.cwd,
upFlags: inputs.upFlags, upFlags: inputs.upFlags,
services: inputs.services, services: inputs.services,
serviceLogger: loggerService.getServiceLogger(inputs.serviceLogLevel), serviceLogger: loggerService.getServiceLogger(inputs.serviceLogLevel),
}); });
loggerService.info("docker compose service(s) are up"); loggerService.info("docker compose service(s) are up");
} catch (error) { } catch (error) {
setFailed(`${error instanceof Error ? error : JSON.stringify(error)}`); setFailed(`${error instanceof Error ? error : JSON.stringify(error)}`);
} }
} }

View File

@ -1,109 +1,127 @@
import { jest, describe, it, expect, beforeEach } from "@jest/globals"; import { describe, expect, it, beforeEach, vi } from "vitest";
// Mock @actions/core // Mock @actions/core
const setFailedMock = jest.fn(); const setFailedMock = vi.fn();
jest.unstable_mockModule("@actions/core", () => ({ vi.doMock("@actions/core", () => ({
setFailed: setFailedMock, setFailed: setFailedMock,
getInput: jest.fn().mockReturnValue(""), getInput: vi.fn().mockReturnValue(""),
getMultilineInput: jest.fn().mockReturnValue([]), getMultilineInput: vi.fn().mockReturnValue([]),
debug: jest.fn(), debug: vi.fn(),
info: jest.fn(), info: vi.fn(),
warning: jest.fn(), warning: vi.fn(),
})); }));
// Mock docker-compose // Mock docker-compose
jest.unstable_mockModule("docker-compose", () => ({ vi.doMock("docker-compose", () => ({
upAll: jest.fn(), upAll: vi.fn(),
upMany: jest.fn(), upMany: vi.fn(),
down: jest.fn(), down: vi.fn(),
logs: jest.fn(), logs: vi.fn(),
version: jest version: vi
.fn<() => Promise<{ data: { version: string } }>>() .fn<() => Promise<{ data: { version: string } }>>()
.mockResolvedValue({ data: { version: "1.2.3" } }), .mockResolvedValue({ data: { version: "1.2.3" } }),
})); }));
// Mock node:fs // Mock node:fs
jest.unstable_mockModule("node:fs", async () => { vi.doMock("node:fs", async () => {
const actualFs = await jest.requireActual<typeof import("node:fs")>("node:fs"); const actualFs = await vi.importActual<typeof import("node:fs")>("node:fs");
return { return {
...actualFs, ...actualFs,
existsSync: jest.fn().mockReturnValue(true), existsSync: vi.fn().mockReturnValue(true),
default: { default: {
...actualFs, ...actualFs,
existsSync: jest.fn().mockReturnValue(true), existsSync: vi.fn().mockReturnValue(true),
}, },
}; };
}); });
// Dynamic imports after mock setup // Dynamic imports after mock setup
const { InputService } = await import("./services/input.service.js"); const { InputService } = await import("./services/input.service.js");
const { LoggerService, LogLevel } = await import("./services/logger.service.js"); const { LoggerService, LogLevel } = await import(
const { DockerComposeInstallerService } = "./services/logger.service.js"
await import("./services/docker-compose-installer.service.js"); );
const { DockerComposeService } = await import("./services/docker-compose.service.js"); const { DockerComposeInstallerService } = await import(
"./services/docker-compose-installer.service.js"
);
const { DockerComposeService } = await import(
"./services/docker-compose.service.js"
);
let getInputsMock: jest.SpiedFunction<typeof InputService.prototype.getInputs>; let getInputsMock: ReturnType<typeof vi.spyOn>;
let debugMock: jest.SpiedFunction<typeof LoggerService.prototype.debug>; let debugMock: ReturnType<typeof vi.spyOn>;
let infoMock: jest.SpiedFunction<typeof LoggerService.prototype.info>; let infoMock: ReturnType<typeof vi.spyOn>;
let installMock: jest.SpiedFunction<typeof DockerComposeInstallerService.prototype.install>; let installMock: ReturnType<typeof vi.spyOn>;
let upMock: jest.SpiedFunction<typeof DockerComposeService.prototype.up>; let upMock: ReturnType<typeof vi.spyOn>;
describe("index", () => { describe("index", () => {
beforeEach(() => { beforeEach(() => {
jest.clearAllMocks(); vi.clearAllMocks();
infoMock = jest.spyOn(LoggerService.prototype, "info").mockImplementation(() => {}); infoMock = vi
debugMock = jest.spyOn(LoggerService.prototype, "debug").mockImplementation(() => {}); .spyOn(LoggerService.prototype, "info")
getInputsMock = jest.spyOn(InputService.prototype, "getInputs"); .mockImplementation(() => {});
installMock = jest.spyOn(DockerComposeInstallerService.prototype, "install"); debugMock = vi
upMock = jest.spyOn(DockerComposeService.prototype, "up"); .spyOn(LoggerService.prototype, "debug")
}); .mockImplementation(() => {});
getInputsMock = vi.spyOn(InputService.prototype, "getInputs");
installMock = vi.spyOn(DockerComposeInstallerService.prototype, "install");
upMock = vi.spyOn(DockerComposeService.prototype, "up");
});
it("calls run when imported", async () => { it("calls run when imported", async () => {
getInputsMock.mockImplementation(() => ({ getInputsMock.mockImplementation(() => ({
dockerFlags: [], dockerFlags: [],
composeFiles: ["docker-compose.yml"], composeFiles: ["docker-compose.yml"],
services: [], services: [],
composeFlags: [], composeFlags: [],
upFlags: [], upFlags: [],
downFlags: [], downFlags: [],
cwd: "/current/working/dir", cwd: "/current/working/dir",
composeVersion: null, composeVersion: null,
githubToken: null, githubToken: null,
serviceLogLevel: LogLevel.Debug, serviceLogLevel: LogLevel.Debug,
})); }));
installMock.mockResolvedValue("1.2.3"); installMock.mockResolvedValue("1.2.3");
upMock.mockResolvedValueOnce(); upMock.mockResolvedValueOnce();
await import("./index.js"); await import("./index.js");
await new Promise((resolve) => setTimeout(resolve, 0)); await new Promise((resolve) => setTimeout(resolve, 0));
expect(infoMock).toHaveBeenNthCalledWith(1, "Setting up docker compose"); expect(infoMock).toHaveBeenNthCalledWith(1, "Setting up docker compose");
expect(infoMock).toHaveBeenNthCalledWith(2, "docker compose version: 1.2.3"); expect(infoMock).toHaveBeenNthCalledWith(
2,
"docker compose version: 1.2.3",
);
// Verify that all of the functions were called correctly // Verify that all of the functions were called correctly
expect(debugMock).toHaveBeenNthCalledWith( expect(debugMock).toHaveBeenNthCalledWith(
1, 1,
'inputs: {"dockerFlags":[],"composeFiles":["docker-compose.yml"],"services":[],"composeFlags":[],"upFlags":[],"downFlags":[],"cwd":"/current/working/dir","composeVersion":null,"githubToken":null,"serviceLogLevel":"debug"}' 'inputs: {"dockerFlags":[],"composeFiles":["docker-compose.yml"],"services":[],"composeFlags":[],"upFlags":[],"downFlags":[],"cwd":"/current/working/dir","composeVersion":null,"githubToken":null,"serviceLogLevel":"debug"}',
); );
expect(infoMock).toHaveBeenNthCalledWith(3, "Bringing up docker compose service(s)"); expect(infoMock).toHaveBeenNthCalledWith(
3,
"Bringing up docker compose service(s)",
);
expect(upMock).toHaveBeenCalledWith({ expect(upMock).toHaveBeenCalledWith({
dockerFlags: [], dockerFlags: [],
composeFiles: ["docker-compose.yml"], composeFiles: ["docker-compose.yml"],
services: [], services: [],
composeFlags: [], composeFlags: [],
upFlags: [], upFlags: [],
cwd: "/current/working/dir", cwd: "/current/working/dir",
serviceLogger: debugMock, serviceLogger: debugMock,
}); });
expect(setFailedMock).not.toHaveBeenCalled(); expect(setFailedMock).not.toHaveBeenCalled();
expect(infoMock).toHaveBeenNthCalledWith(4, "docker compose service(s) are up"); expect(infoMock).toHaveBeenNthCalledWith(
}); 4,
"docker compose service(s) are up",
);
});
}); });

View File

@ -1,193 +1,205 @@
import { jest, describe, it, expect, beforeEach } from "@jest/globals"; import { describe, expect, it, beforeEach, vi } from "vitest";
// Mock @actions/core // Mock @actions/core
const setFailedMock = jest.fn(); const setFailedMock = vi.fn();
jest.unstable_mockModule("@actions/core", () => ({ vi.doMock("@actions/core", () => ({
setFailed: setFailedMock, setFailed: setFailedMock,
getInput: jest.fn().mockReturnValue(""), getInput: vi.fn().mockReturnValue(""),
getMultilineInput: jest.fn().mockReturnValue([]), getMultilineInput: vi.fn().mockReturnValue([]),
debug: jest.fn(), debug: vi.fn(),
info: jest.fn(), info: vi.fn(),
warning: jest.fn(), warning: vi.fn(),
})); }));
// Mock docker-compose // Mock docker-compose
const logsMock = jest.fn(); const logsMock = vi.fn();
const downMock = jest.fn(); const downMock = vi.fn();
jest.unstable_mockModule("docker-compose", () => ({ vi.doMock("docker-compose", () => ({
logs: logsMock, logs: logsMock,
down: downMock, down: downMock,
upAll: jest.fn(), upAll: vi.fn(),
upMany: jest.fn(), upMany: vi.fn(),
})); }));
// Mock node:fs // Mock node:fs
jest.unstable_mockModule("node:fs", () => ({ vi.doMock("node:fs", () => ({
existsSync: jest.fn().mockReturnValue(true), existsSync: vi.fn().mockReturnValue(true),
default: { existsSync: jest.fn().mockReturnValue(true) }, default: { existsSync: vi.fn().mockReturnValue(true) },
})); }));
// Dynamic imports after mock setup // Dynamic imports after mock setup
const { run } = await import("./post-runner.js"); const { run } = await import("./post-runner.js");
const { InputService } = await import("./services/input.service.js"); const { InputService } = await import("./services/input.service.js");
const { LoggerService, LogLevel } = await import("./services/logger.service.js"); const { LoggerService, LogLevel } = await import(
const { DockerComposeService } = await import("./services/docker-compose.service.js"); "./services/logger.service.js"
);
const { DockerComposeService } = await import(
"./services/docker-compose.service.js"
);
describe("run", () => { describe("run", () => {
let infoMock: jest.SpiedFunction<typeof LoggerService.prototype.info>; let infoMock: ReturnType<typeof vi.spyOn>;
let debugMock: jest.SpiedFunction<typeof LoggerService.prototype.debug>; let debugMock: ReturnType<typeof vi.spyOn>;
let getInputsMock: jest.SpiedFunction<typeof InputService.prototype.getInputs>; let getInputsMock: ReturnType<typeof vi.spyOn>;
let serviceDownMock: jest.SpiedFunction<typeof DockerComposeService.prototype.down>; let serviceDownMock: ReturnType<typeof vi.spyOn>;
let serviceLogsMock: jest.SpiedFunction<typeof DockerComposeService.prototype.logs>; let serviceLogsMock: ReturnType<typeof vi.spyOn>;
beforeEach(() => { beforeEach(() => {
jest.clearAllMocks(); vi.clearAllMocks();
infoMock = jest.spyOn(LoggerService.prototype, "info").mockImplementation(() => {}); infoMock = vi
debugMock = jest.spyOn(LoggerService.prototype, "debug").mockImplementation(() => {}); .spyOn(LoggerService.prototype, "info")
getInputsMock = jest.spyOn(InputService.prototype, "getInputs"); .mockImplementation(() => {});
serviceDownMock = jest.spyOn(DockerComposeService.prototype, "down"); debugMock = vi
serviceLogsMock = jest.spyOn(DockerComposeService.prototype, "logs"); .spyOn(LoggerService.prototype, "debug")
}); .mockImplementation(() => {});
getInputsMock = vi.spyOn(InputService.prototype, "getInputs");
serviceDownMock = vi.spyOn(DockerComposeService.prototype, "down");
serviceLogsMock = vi.spyOn(DockerComposeService.prototype, "logs");
});
it("should bring down docker compose service(s) and log output", async () => { it("should bring down docker compose service(s) and log output", async () => {
// Arrange // Arrange
getInputsMock.mockImplementation(() => ({ getInputsMock.mockImplementation(() => ({
dockerFlags: [], dockerFlags: [],
composeFiles: ["docker-compose.yml"], composeFiles: ["docker-compose.yml"],
services: [], services: [],
composeFlags: [], composeFlags: [],
upFlags: [], upFlags: [],
downFlags: [], downFlags: [],
cwd: "/current/working/dir", cwd: "/current/working/dir",
composeVersion: null, composeVersion: null,
githubToken: null, githubToken: null,
serviceLogLevel: LogLevel.Debug, serviceLogLevel: LogLevel.Debug,
})); }));
serviceLogsMock.mockResolvedValue({ error: "", output: "test logs" }); serviceLogsMock.mockResolvedValue({ error: "", output: "test logs" });
serviceDownMock.mockResolvedValue(); serviceDownMock.mockResolvedValue();
// Act // Act
await run(); await run();
// Assert // Assert
expect(serviceLogsMock).toHaveBeenCalledWith({ expect(serviceLogsMock).toHaveBeenCalledWith({
dockerFlags: [], dockerFlags: [],
composeFiles: ["docker-compose.yml"], composeFiles: ["docker-compose.yml"],
composeFlags: [], composeFlags: [],
cwd: "/current/working/dir", cwd: "/current/working/dir",
services: [], services: [],
serviceLogger: debugMock, serviceLogger: debugMock,
}); });
expect(serviceDownMock).toHaveBeenCalledWith({ expect(serviceDownMock).toHaveBeenCalledWith({
dockerFlags: [], dockerFlags: [],
composeFiles: ["docker-compose.yml"], composeFiles: ["docker-compose.yml"],
composeFlags: [], composeFlags: [],
cwd: "/current/working/dir", cwd: "/current/working/dir",
downFlags: [], downFlags: [],
serviceLogger: debugMock, serviceLogger: debugMock,
}); });
expect(debugMock).toHaveBeenCalledWith("docker compose logs:\ntest logs"); expect(debugMock).toHaveBeenCalledWith("docker compose logs:\ntest logs");
expect(infoMock).toHaveBeenCalledWith("docker compose is down"); expect(infoMock).toHaveBeenCalledWith("docker compose is down");
expect(setFailedMock).not.toHaveBeenCalled(); expect(setFailedMock).not.toHaveBeenCalled();
}); });
it("should log docker composer errors if any", async () => { it("should log docker composer errors if any", async () => {
// Arrange // Arrange
getInputsMock.mockImplementation(() => ({ getInputsMock.mockImplementation(() => ({
dockerFlags: [], dockerFlags: [],
composeFiles: ["docker-compose.yml"], composeFiles: ["docker-compose.yml"],
services: [], services: [],
composeFlags: [], composeFlags: [],
upFlags: [], upFlags: [],
downFlags: [], downFlags: [],
cwd: "/current/working/dir", cwd: "/current/working/dir",
composeVersion: null, composeVersion: null,
githubToken: null, githubToken: null,
serviceLogLevel: LogLevel.Debug, serviceLogLevel: LogLevel.Debug,
})); }));
serviceLogsMock.mockResolvedValue({ serviceLogsMock.mockResolvedValue({
error: "test logs error", error: "test logs error",
output: "test logs output", output: "test logs output",
}); });
serviceDownMock.mockResolvedValue(); serviceDownMock.mockResolvedValue();
// Act // Act
await run(); await run();
// Assert // Assert
expect(debugMock).toHaveBeenCalledWith("docker compose error:\ntest logs error"); expect(debugMock).toHaveBeenCalledWith(
expect(debugMock).toHaveBeenCalledWith("docker compose logs:\ntest logs output"); "docker compose error:\ntest logs error",
expect(infoMock).toHaveBeenCalledWith("docker compose is down"); );
}); expect(debugMock).toHaveBeenCalledWith(
"docker compose logs:\ntest logs output",
);
expect(infoMock).toHaveBeenCalledWith("docker compose is down");
});
it("should set failed when an error occurs", async () => { it("should set failed when an error occurs", async () => {
// Arrange // Arrange
getInputsMock.mockImplementation(() => { getInputsMock.mockImplementation(() => {
throw new Error("An error occurred"); throw new Error("An error occurred");
}); });
// Act // Act
await run(); await run();
// Assert // Assert
expect(setFailedMock).toHaveBeenCalledWith("Error: An error occurred"); expect(setFailedMock).toHaveBeenCalledWith("Error: An error occurred");
}); });
it("should handle errors and call setFailed", async () => { it("should handle errors and call setFailed", async () => {
// Arrange // Arrange
const error = new Error("Test error"); const error = new Error("Test error");
serviceDownMock.mockRejectedValue(error); serviceDownMock.mockRejectedValue(error);
getInputsMock.mockImplementation(() => ({ getInputsMock.mockImplementation(() => ({
dockerFlags: [], dockerFlags: [],
composeFiles: ["docker-compose.yml"], composeFiles: ["docker-compose.yml"],
services: ["web"], services: ["web"],
composeFlags: [], composeFlags: [],
upFlags: [], upFlags: [],
downFlags: [], downFlags: [],
cwd: "/current/working/dir", cwd: "/current/working/dir",
composeVersion: null, composeVersion: null,
githubToken: null, githubToken: null,
serviceLogLevel: LogLevel.Debug, serviceLogLevel: LogLevel.Debug,
})); }));
// Act // Act
await run(); await run();
// Assert // Assert
expect(setFailedMock).toHaveBeenCalledWith("Error: Test error"); expect(setFailedMock).toHaveBeenCalledWith("Error: Test error");
}); });
it("should handle unknown errors and call setFailed", async () => { it("should handle unknown errors and call setFailed", async () => {
// Arrange // Arrange
const error = "Test error"; const error = "Test error";
serviceDownMock.mockRejectedValue(error); serviceDownMock.mockRejectedValue(error);
getInputsMock.mockImplementation(() => ({ getInputsMock.mockImplementation(() => ({
dockerFlags: [], dockerFlags: [],
composeFiles: ["docker-compose.yml"], composeFiles: ["docker-compose.yml"],
services: ["web"], services: ["web"],
composeFlags: [], composeFlags: [],
upFlags: [], upFlags: [],
downFlags: [], downFlags: [],
cwd: "/current/working/dir", cwd: "/current/working/dir",
composeVersion: null, composeVersion: null,
githubToken: null, githubToken: null,
serviceLogLevel: LogLevel.Debug, serviceLogLevel: LogLevel.Debug,
})); }));
// Act // Act
await run(); await run();
// Assert // Assert
expect(setFailedMock).toHaveBeenCalledWith('"Test error"'); expect(setFailedMock).toHaveBeenCalledWith('"Test error"');
}); });
}); });

View File

@ -8,39 +8,39 @@ import { DockerComposeService } from "./services/docker-compose.service.js";
* @returns {Promise<void>} Resolves when the action is complete. * @returns {Promise<void>} Resolves when the action is complete.
*/ */
export async function run(): Promise<void> { export async function run(): Promise<void> {
try { try {
const loggerService = new LoggerService(); const loggerService = new LoggerService();
const inputService = new InputService(); const inputService = new InputService();
const dockerComposeService = new DockerComposeService(); const dockerComposeService = new DockerComposeService();
const inputs = inputService.getInputs(); const inputs = inputService.getInputs();
const { error, output } = await dockerComposeService.logs({ const { error, output } = await dockerComposeService.logs({
dockerFlags: inputs.dockerFlags, dockerFlags: inputs.dockerFlags,
composeFiles: inputs.composeFiles, composeFiles: inputs.composeFiles,
composeFlags: inputs.composeFlags, composeFlags: inputs.composeFlags,
cwd: inputs.cwd, cwd: inputs.cwd,
services: inputs.services, services: inputs.services,
serviceLogger: loggerService.getServiceLogger(inputs.serviceLogLevel), serviceLogger: loggerService.getServiceLogger(inputs.serviceLogLevel),
}); });
if (error) { if (error) {
loggerService.debug("docker compose error:\n" + error); loggerService.debug("docker compose error:\n" + error);
} }
loggerService.debug("docker compose logs:\n" + output); loggerService.debug("docker compose logs:\n" + output);
await dockerComposeService.down({ await dockerComposeService.down({
dockerFlags: inputs.dockerFlags, dockerFlags: inputs.dockerFlags,
composeFiles: inputs.composeFiles, composeFiles: inputs.composeFiles,
composeFlags: inputs.composeFlags, composeFlags: inputs.composeFlags,
cwd: inputs.cwd, cwd: inputs.cwd,
downFlags: inputs.downFlags, downFlags: inputs.downFlags,
serviceLogger: loggerService.getServiceLogger(inputs.serviceLogLevel), serviceLogger: loggerService.getServiceLogger(inputs.serviceLogLevel),
}); });
loggerService.info("docker compose is down"); loggerService.info("docker compose is down");
} catch (error) { } catch (error) {
setFailed(`${error instanceof Error ? error : JSON.stringify(error)}`); setFailed(`${error instanceof Error ? error : JSON.stringify(error)}`);
} }
} }

View File

@ -1,97 +1,108 @@
import { jest, describe, it, expect, beforeEach } from "@jest/globals"; import { describe, expect, it, beforeEach, vi } from "vitest";
// Mock @actions/core // Mock @actions/core
const setFailedMock = jest.fn(); const setFailedMock = vi.fn();
jest.unstable_mockModule("@actions/core", () => ({ vi.doMock("@actions/core", () => ({
setFailed: setFailedMock, setFailed: setFailedMock,
getInput: jest.fn().mockReturnValue(""), getInput: vi.fn().mockReturnValue(""),
getMultilineInput: jest.fn().mockReturnValue([]), getMultilineInput: vi.fn().mockReturnValue([]),
debug: jest.fn(), debug: vi.fn(),
info: jest.fn(), info: vi.fn(),
warning: jest.fn(), warning: vi.fn(),
})); }));
// Mock docker-compose // Mock docker-compose
const logsMock = jest.fn(); const logsMock = vi.fn();
const downMock = jest.fn(); const downMock = vi.fn();
jest.unstable_mockModule("docker-compose", () => ({ vi.doMock("docker-compose", () => ({
logs: logsMock, logs: logsMock,
down: downMock, down: downMock,
upAll: jest.fn(), upAll: vi.fn(),
upMany: jest.fn(), upMany: vi.fn(),
})); }));
// Mock node:fs // Mock node:fs
jest.unstable_mockModule("node:fs", () => ({ vi.doMock("node:fs", () => ({
existsSync: jest.fn().mockReturnValue(true), existsSync: vi.fn().mockReturnValue(true),
default: { existsSync: jest.fn().mockReturnValue(true) }, default: { existsSync: vi.fn().mockReturnValue(true) },
})); }));
// Dynamic imports after mock setup // Dynamic imports after mock setup
const { InputService } = await import("./services/input.service.js"); const { InputService } = await import("./services/input.service.js");
const { LoggerService, LogLevel } = await import("./services/logger.service.js"); const { LoggerService, LogLevel } = await import(
const { DockerComposeService } = await import("./services/docker-compose.service.js"); "./services/logger.service.js"
);
const { DockerComposeService } = await import(
"./services/docker-compose.service.js"
);
let getInputsMock: jest.SpiedFunction<typeof InputService.prototype.getInputs>; let getInputsMock: ReturnType<typeof vi.spyOn>;
let debugMock: jest.SpiedFunction<typeof LoggerService.prototype.debug>; let debugMock: ReturnType<typeof vi.spyOn>;
let infoMock: jest.SpiedFunction<typeof LoggerService.prototype.info>; let infoMock: ReturnType<typeof vi.spyOn>;
let serviceLogsMock: jest.SpiedFunction<typeof DockerComposeService.prototype.logs>; let serviceLogsMock: ReturnType<typeof vi.spyOn>;
let serviceDownMock: jest.SpiedFunction<typeof DockerComposeService.prototype.down>; let serviceDownMock: ReturnType<typeof vi.spyOn>;
describe("post", () => { describe("post", () => {
beforeEach(() => { beforeEach(() => {
jest.clearAllMocks(); vi.clearAllMocks();
infoMock = jest.spyOn(LoggerService.prototype, "info").mockImplementation(() => {}); infoMock = vi
debugMock = jest.spyOn(LoggerService.prototype, "debug").mockImplementation(() => {}); .spyOn(LoggerService.prototype, "info")
getInputsMock = jest.spyOn(InputService.prototype, "getInputs"); .mockImplementation(() => {});
serviceLogsMock = jest.spyOn(DockerComposeService.prototype, "logs"); debugMock = vi
serviceDownMock = jest.spyOn(DockerComposeService.prototype, "down"); .spyOn(LoggerService.prototype, "debug")
}); .mockImplementation(() => {});
getInputsMock = vi.spyOn(InputService.prototype, "getInputs");
serviceLogsMock = vi.spyOn(DockerComposeService.prototype, "logs");
serviceDownMock = vi.spyOn(DockerComposeService.prototype, "down");
});
it("calls run when imported", async () => { it("calls run when imported", async () => {
getInputsMock.mockImplementation(() => ({ getInputsMock.mockImplementation(() => ({
dockerFlags: [], dockerFlags: [],
composeFiles: ["docker-compose.yml"], composeFiles: ["docker-compose.yml"],
services: [], services: [],
composeFlags: [], composeFlags: [],
upFlags: [], upFlags: [],
downFlags: [], downFlags: [],
cwd: "/current/working/dir", cwd: "/current/working/dir",
composeVersion: null, composeVersion: null,
githubToken: null, githubToken: null,
serviceLogLevel: LogLevel.Debug, serviceLogLevel: LogLevel.Debug,
})); }));
serviceLogsMock.mockResolvedValue({ error: "", output: "test logs" }); serviceLogsMock.mockResolvedValue({ error: "", output: "test logs" });
serviceDownMock.mockResolvedValueOnce(); serviceDownMock.mockResolvedValueOnce();
await import("./post.js"); await import("./post.js");
await new Promise((resolve) => setTimeout(resolve, 0)); await new Promise((resolve) => setTimeout(resolve, 0));
expect(serviceLogsMock).toHaveBeenCalledWith({ expect(serviceLogsMock).toHaveBeenCalledWith({
dockerFlags: [], dockerFlags: [],
composeFiles: ["docker-compose.yml"], composeFiles: ["docker-compose.yml"],
composeFlags: [], composeFlags: [],
cwd: "/current/working/dir", cwd: "/current/working/dir",
services: [], services: [],
serviceLogger: debugMock, serviceLogger: debugMock,
}); });
expect(serviceDownMock).toHaveBeenCalledWith({ expect(serviceDownMock).toHaveBeenCalledWith({
dockerFlags: [], dockerFlags: [],
composeFiles: ["docker-compose.yml"], composeFiles: ["docker-compose.yml"],
composeFlags: [], composeFlags: [],
cwd: "/current/working/dir", cwd: "/current/working/dir",
downFlags: [], downFlags: [],
serviceLogger: debugMock, serviceLogger: debugMock,
}); });
expect(debugMock).toHaveBeenNthCalledWith(1, "docker compose logs:\ntest logs"); expect(debugMock).toHaveBeenNthCalledWith(
expect(infoMock).toHaveBeenNthCalledWith(1, "docker compose is down"); 1,
"docker compose logs:\ntest logs",
);
expect(infoMock).toHaveBeenNthCalledWith(1, "docker compose is down");
expect(setFailedMock).not.toHaveBeenCalled(); expect(setFailedMock).not.toHaveBeenCalled();
}); });
}); });

View File

@ -1,272 +1,302 @@
import { jest, describe, it, expect, beforeEach, afterEach } from "@jest/globals"; import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import type { IDockerComposeResult } from "docker-compose"; import type { IDockerComposeResult } from "docker-compose";
import { MockAgent, setGlobalDispatcher } from "undici"; import { MockAgent, setGlobalDispatcher } from "undici";
// Mock docker-compose before importing the module under test // Mock docker-compose before importing the module under test
const versionMock = jest.fn<() => Promise<IDockerComposeResult & { data: { version: string } }>>(); const versionMock =
vi.fn<() => Promise<IDockerComposeResult & { data: { version: string } }>>();
jest.unstable_mockModule("docker-compose", () => ({ vi.doMock("docker-compose", () => ({
version: versionMock, version: versionMock,
})); }));
// Create manual installer adapter mock // Create manual installer adapter mock
const manualInstallerAdapterMock = { const manualInstallerAdapterMock = {
install: jest.fn<(version: string) => Promise<void>>(), install: vi.fn<(version: string) => Promise<void>>(),
}; };
// Dynamic import after mock setup // Dynamic import after mock setup
const { DockerComposeInstallerService } = await import("./docker-compose-installer.service.js"); const { DockerComposeInstallerService } = await import(
"./docker-compose-installer.service.js"
);
describe("DockerComposeInstallerService", () => { describe("DockerComposeInstallerService", () => {
let mockAgent: MockAgent; let mockAgent: MockAgent;
let service: InstanceType<typeof DockerComposeInstallerService>; let service: InstanceType<typeof DockerComposeInstallerService>;
const composeVersionResponse = (version: string) => ({ const composeVersionResponse = (version: string) => ({
exitCode: 0, exitCode: 0,
out: "", out: "",
err: "", err: "",
data: { data: {
version, version,
}, },
}); });
const installCompose = (composeVersion: string | null, githubToken: string | null) => const installCompose = (
service.install({ composeVersion: string | null,
composeVersion, githubToken: string | null,
cwd: "/path/to/cwd", ) =>
githubToken, service.install({
}); composeVersion,
cwd: "/path/to/cwd",
githubToken,
});
const setPlatform = (platform: NodeJS.Platform) => { const setPlatform = (platform: NodeJS.Platform) => {
Object.defineProperty(process, "platform", { Object.defineProperty(process, "platform", {
value: platform, value: platform,
}); });
}; };
const mockLatestRelease = (version: string) => { const mockLatestRelease = (version: string) => {
const mockClient = mockAgent.get("https://api.github.com"); const mockClient = mockAgent.get("https://api.github.com");
mockClient mockClient
.intercept({ .intercept({
path: "/repos/docker/compose/releases/latest", path: "/repos/docker/compose/releases/latest",
method: "GET", method: "GET",
}) })
.reply( .reply(
200, 200,
{ {
tag_name: version, tag_name: version,
}, },
{ {
headers: { headers: {
"content-type": "application/json", "content-type": "application/json",
}, },
} },
); );
setGlobalDispatcher(mockClient); setGlobalDispatcher(mockClient);
Object.defineProperty(globalThis, "fetch", { Object.defineProperty(globalThis, "fetch", {
value: jest.fn(), value: vi.fn(),
}); });
}; };
beforeEach(() => { beforeEach(() => {
jest.clearAllMocks(); vi.clearAllMocks();
mockAgent = new MockAgent(); mockAgent = new MockAgent();
mockAgent.disableNetConnect(); mockAgent.disableNetConnect();
service = new DockerComposeInstallerService(manualInstallerAdapterMock as never); service = new DockerComposeInstallerService(
}); manualInstallerAdapterMock as never,
);
});
afterEach(() => { afterEach(() => {
jest.resetAllMocks(); vi.resetAllMocks();
}); });
describe("install", () => { describe("install", () => {
it("should install latest when compose version is not specified and Compose is missing", async () => { it("should install latest when compose version is not specified and Compose is missing", async () => {
// Arrange: first call to version() fails (Compose missing) // Arrange: first call to version() fails (Compose missing)
versionMock.mockRejectedValueOnce(new Error("version not installed")); versionMock.mockRejectedValueOnce(new Error("version not installed"));
const latestVersion = "v2.0.0"; const latestVersion = "v2.0.0";
mockLatestRelease(latestVersion); mockLatestRelease(latestVersion);
versionMock.mockResolvedValueOnce(composeVersionResponse(latestVersion)); versionMock.mockResolvedValueOnce(composeVersionResponse(latestVersion));
setPlatform("linux"); setPlatform("linux");
// Act // Act
const result = await installCompose(null, "token"); const result = await installCompose(null, "token");
// Assert // Assert
expect(result).toBe(latestVersion); expect(result).toBe(latestVersion);
expect(manualInstallerAdapterMock.install).toHaveBeenCalledWith(latestVersion); expect(manualInstallerAdapterMock.install).toHaveBeenCalledWith(
}); latestVersion,
);
});
it("should return current version when no version is provided", async () => { it("should return current version when no version is provided", async () => {
// Arrange // Arrange
versionMock.mockResolvedValue(composeVersionResponse("2.0.0")); versionMock.mockResolvedValue(composeVersionResponse("2.0.0"));
// Act // Act
const result = await installCompose(null, null); const result = await installCompose(null, null);
// Assert // Assert
expect(result).toBe("2.0.0"); expect(result).toBe("2.0.0");
expect(manualInstallerAdapterMock.install).not.toHaveBeenCalled(); expect(manualInstallerAdapterMock.install).not.toHaveBeenCalled();
}); });
it("should not install anything when expected version is already installed", async () => { it("should not install anything when expected version is already installed", async () => {
// Arrange // Arrange
versionMock.mockResolvedValue(composeVersionResponse("1.2.3")); versionMock.mockResolvedValue(composeVersionResponse("1.2.3"));
// Act // Act
const result = await installCompose("v1.2.3", null); const result = await installCompose("v1.2.3", null);
// Assert // Assert
expect(result).toBe("1.2.3"); expect(result).toBe("1.2.3");
expect(manualInstallerAdapterMock.install).not.toHaveBeenCalled(); expect(manualInstallerAdapterMock.install).not.toHaveBeenCalled();
}); });
it("should install the requested version if it is not already installed", async () => { it("should install the requested version if it is not already installed", async () => {
// Arrange // Arrange
versionMock.mockResolvedValueOnce(composeVersionResponse("1.2.3")); versionMock.mockResolvedValueOnce(composeVersionResponse("1.2.3"));
const expectedVersion = "1.3.0"; const expectedVersion = "1.3.0";
versionMock.mockResolvedValueOnce(composeVersionResponse(expectedVersion)); versionMock.mockResolvedValueOnce(
setPlatform("linux"); composeVersionResponse(expectedVersion),
);
setPlatform("linux");
// Act // Act
const result = await installCompose(expectedVersion, null); const result = await installCompose(expectedVersion, null);
// Assert // Assert
expect(result).toBe(expectedVersion); expect(result).toBe(expectedVersion);
expect(manualInstallerAdapterMock.install).toHaveBeenCalledWith(expectedVersion); expect(manualInstallerAdapterMock.install).toHaveBeenCalledWith(
}); expectedVersion,
);
});
it("should install the latest version if requested", async () => { it("should install the latest version if requested", async () => {
// Arrange // Arrange
versionMock.mockResolvedValueOnce(composeVersionResponse("1.2.3")); versionMock.mockResolvedValueOnce(composeVersionResponse("1.2.3"));
const latestVersion = "v1.4.0"; const latestVersion = "v1.4.0";
mockLatestRelease(latestVersion); mockLatestRelease(latestVersion);
versionMock.mockResolvedValueOnce(composeVersionResponse(latestVersion)); versionMock.mockResolvedValueOnce(composeVersionResponse(latestVersion));
setPlatform("linux"); setPlatform("linux");
// Act // Act
const result = await installCompose("latest", "token"); const result = await installCompose("latest", "token");
// Assert // Assert
expect(result).toBe(latestVersion); expect(result).toBe(latestVersion);
expect(manualInstallerAdapterMock.install).toHaveBeenCalledWith(latestVersion); expect(manualInstallerAdapterMock.install).toHaveBeenCalledWith(
}); latestVersion,
);
});
it("should throw an error if the latest version if requested and no Github token is provided", async () => { it("should throw an error if the latest version if requested and no Github token is provided", async () => {
// Arrange // Arrange
versionMock.mockResolvedValueOnce(composeVersionResponse("1.2.3")); versionMock.mockResolvedValueOnce(composeVersionResponse("1.2.3"));
// Act & Assert // Act & Assert
await expect(installCompose("latest", null)).rejects.toThrow( await expect(installCompose("latest", null)).rejects.toThrow(
"GitHub token is required to install the latest version" "GitHub token is required to install the latest version",
); );
}); });
it("should throw an error on unsupported platforms", async () => { it("should throw an error on unsupported platforms", async () => {
// Arrange // Arrange
versionMock.mockResolvedValueOnce(composeVersionResponse("1.2.3")); versionMock.mockResolvedValueOnce(composeVersionResponse("1.2.3"));
const expectedVersion = "1.3.0"; const expectedVersion = "1.3.0";
versionMock.mockResolvedValueOnce(composeVersionResponse(expectedVersion)); versionMock.mockResolvedValueOnce(
setPlatform("win32"); composeVersionResponse(expectedVersion),
);
setPlatform("win32");
// Act & Assert // Act & Assert
await expect(installCompose(expectedVersion, null)).rejects.toThrow( await expect(installCompose(expectedVersion, null)).rejects.toThrow(
`Unsupported platform: win32` `Unsupported platform: win32`,
); );
expect(manualInstallerAdapterMock.install).not.toHaveBeenCalled(); expect(manualInstallerAdapterMock.install).not.toHaveBeenCalled();
}); });
it("should install when version check fails", async () => { it("should install when version check fails", async () => {
// Arrange: first call to version() doesn't find // Arrange: first call to version() doesn't find
versionMock.mockRejectedValueOnce(new Error("version not installed")); versionMock.mockRejectedValueOnce(new Error("version not installed"));
const installedVersion = "2.0.0"; const installedVersion = "2.0.0";
// After installation, version() returns the new version // After installation, version() returns the new version
versionMock.mockResolvedValueOnce(composeVersionResponse(installedVersion)); versionMock.mockResolvedValueOnce(
setPlatform("linux"); composeVersionResponse(installedVersion),
);
setPlatform("linux");
// Act // Act
const result = await installCompose(installedVersion, "token"); const result = await installCompose(installedVersion, "token");
// Assert // Assert
expect(result).toBe(installedVersion); expect(result).toBe(installedVersion);
expect(manualInstallerAdapterMock.install).toHaveBeenCalledWith(installedVersion); expect(manualInstallerAdapterMock.install).toHaveBeenCalledWith(
}); installedVersion,
);
});
it("should install latest version when missing or unspecified", async () => { it("should install latest version when missing or unspecified", async () => {
// Arrange: first call to version() doesn't find // Arrange: first call to version() doesn't find
versionMock.mockRejectedValueOnce(new Error("version check failed")); versionMock.mockRejectedValueOnce(new Error("version check failed"));
// second call finds newly installed version // second call finds newly installed version
versionMock.mockResolvedValueOnce(composeVersionResponse("v1.4.0")); versionMock.mockResolvedValueOnce(composeVersionResponse("v1.4.0"));
const latestVersion = "v1.4.0"; const latestVersion = "v1.4.0";
mockLatestRelease(latestVersion); mockLatestRelease(latestVersion);
versionMock.mockResolvedValueOnce(composeVersionResponse(latestVersion)); versionMock.mockResolvedValueOnce(composeVersionResponse(latestVersion));
setPlatform("linux"); setPlatform("linux");
// Act // Act
const result = await installCompose("latest", "token"); const result = await installCompose("latest", "token");
// Assert // Assert
expect(result).toBe(latestVersion); expect(result).toBe(latestVersion);
expect(manualInstallerAdapterMock.install).toHaveBeenCalledWith(latestVersion); expect(manualInstallerAdapterMock.install).toHaveBeenCalledWith(
}); latestVersion,
);
});
it("should throw if Compose is missing and no GitHub token is provided", async () => { it("should throw if Compose is missing and no GitHub token is provided", async () => {
// Arrange: first call to version() doesn't find // Arrange: first call to version() doesn't find
versionMock.mockRejectedValueOnce(new Error("version check failed")); versionMock.mockRejectedValueOnce(new Error("version check failed"));
setPlatform("linux"); setPlatform("linux");
await expect(installCompose("latest", null)).rejects.toThrow( await expect(installCompose("latest", null)).rejects.toThrow(
"GitHub token is required to install the latest version" "GitHub token is required to install the latest version",
); );
}); });
it("should not install when the version is already installed and no version is specified", async () => { it("should not install when the version is already installed and no version is specified", async () => {
// Arrange // Arrange
versionMock.mockResolvedValue(composeVersionResponse("1.2.3")); versionMock.mockResolvedValue(composeVersionResponse("1.2.3"));
// Act // Act
const result = await installCompose("", null); const result = await installCompose("", null);
// Assert // Assert
expect(result).toBe("1.2.3"); expect(result).toBe("1.2.3");
expect(manualInstallerAdapterMock.install).not.toHaveBeenCalled(); expect(manualInstallerAdapterMock.install).not.toHaveBeenCalled();
}); });
it("should throw when installed version does not match target", async () => { it("should throw when installed version does not match target", async () => {
// Arrange // Arrange
versionMock.mockResolvedValueOnce(composeVersionResponse("1.2.3")); versionMock.mockResolvedValueOnce(composeVersionResponse("1.2.3"));
const targetVersion = "v1.4.0"; const targetVersion = "v1.4.0";
versionMock.mockResolvedValueOnce(composeVersionResponse("1.3.0")); versionMock.mockResolvedValueOnce(composeVersionResponse("1.3.0"));
setPlatform("linux"); setPlatform("linux");
// Act & Assert // Act & Assert
await expect(installCompose(targetVersion, "token")).rejects.toThrow( await expect(installCompose(targetVersion, "token")).rejects.toThrow(
`Failed to install Docker Compose version "${targetVersion}", installed version is "1.3.0"` `Failed to install Docker Compose version "${targetVersion}", installed version is "1.3.0"`,
); );
expect(manualInstallerAdapterMock.install).toHaveBeenCalledWith(targetVersion); expect(manualInstallerAdapterMock.install).toHaveBeenCalledWith(
}); targetVersion,
);
});
it("should throw with unknown installed version when post-install version check fails", async () => { it("should throw with unknown installed version when post-install version check fails", async () => {
// Arrange // Arrange
versionMock.mockResolvedValueOnce(composeVersionResponse("1.2.3")); versionMock.mockResolvedValueOnce(composeVersionResponse("1.2.3"));
const targetVersion = "v1.4.0"; const targetVersion = "v1.4.0";
versionMock.mockRejectedValueOnce(new Error("version check failed after install")); versionMock.mockRejectedValueOnce(
setPlatform("linux"); new Error("version check failed after install"),
);
setPlatform("linux");
// Act & Assert // Act & Assert
await expect(installCompose(targetVersion, "token")).rejects.toThrow( await expect(installCompose(targetVersion, "token")).rejects.toThrow(
`Failed to install Docker Compose version "${targetVersion}", installed version is "unknown"` `Failed to install Docker Compose version "${targetVersion}", installed version is "unknown"`,
); );
expect(manualInstallerAdapterMock.install).toHaveBeenCalledWith(targetVersion); expect(manualInstallerAdapterMock.install).toHaveBeenCalledWith(
}); targetVersion,
}); );
});
});
}); });

View File

@ -1,96 +1,108 @@
import * as github from "@actions/github"; import * as github from "@actions/github";
import { version } from "docker-compose"; import { version } from "docker-compose";
import { COMPOSE_VERSION_LATEST, Inputs } from "./input.service.js"; import { COMPOSE_VERSION_LATEST, type Inputs } from "./input.service.js";
import { ManualInstallerAdapter } from "./installer-adapter/manual-installer-adapter.js"; import type { ManualInstallerAdapter } from "./installer-adapter/manual-installer-adapter.js";
export type InstallInputs = { export type InstallInputs = {
composeVersion: Inputs["composeVersion"]; composeVersion: Inputs["composeVersion"];
cwd: Inputs["cwd"]; cwd: Inputs["cwd"];
githubToken: Inputs["githubToken"]; githubToken: Inputs["githubToken"];
}; };
export type VersionInputs = { export type VersionInputs = {
cwd: Inputs["cwd"]; cwd: Inputs["cwd"];
}; };
export class DockerComposeInstallerService { export class DockerComposeInstallerService {
constructor(private readonly manualInstallerAdapter: ManualInstallerAdapter) {} constructor(
private readonly manualInstallerAdapter: ManualInstallerAdapter,
) {}
async install({ composeVersion, cwd, githubToken }: InstallInputs): Promise<string> { async install({
const currentVersion = await this.version({ cwd }); composeVersion,
cwd,
githubToken,
}: InstallInputs): Promise<string> {
const currentVersion = await this.version({ cwd });
const normalizedCurrentVersion = currentVersion ? this.normalizeVersion(currentVersion) : null; const normalizedCurrentVersion = currentVersion
const normalizedRequestedVersion = composeVersion ? this.normalizeVersion(currentVersion)
? this.normalizeVersion(composeVersion) : null;
: null; const normalizedRequestedVersion = composeVersion
? this.normalizeVersion(composeVersion)
: null;
const needsInstall = const needsInstall =
!currentVersion || !currentVersion ||
(composeVersion && normalizedRequestedVersion !== normalizedCurrentVersion); (composeVersion &&
if (!needsInstall) { normalizedRequestedVersion !== normalizedCurrentVersion);
return currentVersion; if (!needsInstall) {
} return currentVersion;
}
let targetVersion = composeVersion || COMPOSE_VERSION_LATEST; let targetVersion = composeVersion || COMPOSE_VERSION_LATEST;
if (targetVersion === COMPOSE_VERSION_LATEST) { if (targetVersion === COMPOSE_VERSION_LATEST) {
if (!githubToken) { if (!githubToken) {
throw new Error("GitHub token is required to install the latest version"); throw new Error(
} "GitHub token is required to install the latest version",
targetVersion = await this.getLatestVersion(githubToken); );
} }
targetVersion = await this.getLatestVersion(githubToken);
}
await this.installVersion(targetVersion); await this.installVersion(targetVersion);
const installedVersion = await this.version({ cwd }); const installedVersion = await this.version({ cwd });
if ( if (
!installedVersion || !installedVersion ||
this.normalizeVersion(installedVersion) !== this.normalizeVersion(targetVersion) this.normalizeVersion(installedVersion) !==
) { this.normalizeVersion(targetVersion)
throw new Error( ) {
`Failed to install Docker Compose version "${targetVersion}", installed version is "${installedVersion ?? "unknown"}"` throw new Error(
); `Failed to install Docker Compose version "${targetVersion}", installed version is "${installedVersion ?? "unknown"}"`,
} );
}
return installedVersion; return installedVersion;
} }
private async version({ cwd }: VersionInputs): Promise<string | null> { private async version({ cwd }: VersionInputs): Promise<string | null> {
try { try {
const result = await version({ const result = await version({
cwd, cwd,
}); });
return result.data.version; return result.data.version;
} catch { } catch {
// If version check fails (e.g., Docker Compose not installed), return null // If version check fails (e.g., Docker Compose not installed), return null
return null; return null;
} }
} }
private async getLatestVersion(githubToken: string): Promise<string> { private async getLatestVersion(githubToken: string): Promise<string> {
const octokit = github.getOctokit(githubToken); const octokit = github.getOctokit(githubToken);
const response = await octokit.rest.repos.getLatestRelease({ const response = await octokit.rest.repos.getLatestRelease({
owner: "docker", owner: "docker",
repo: "compose", repo: "compose",
}); });
return response.data.tag_name; return response.data.tag_name;
} }
private normalizeVersion(version: string): string { private normalizeVersion(version: string): string {
return version.replace(/^v/i, ""); return version.replace(/^v/i, "");
} }
private async installVersion(version: string): Promise<void> { private async installVersion(version: string): Promise<void> {
switch (process.platform) { switch (process.platform) {
case "linux": case "linux":
case "darwin": case "darwin":
await this.manualInstallerAdapter.install(version); await this.manualInstallerAdapter.install(version);
break; break;
default: default:
throw new Error(`Unsupported platform: ${process.platform}`); throw new Error(`Unsupported platform: ${process.platform}`);
} }
} }
} }

View File

@ -1,340 +1,391 @@
import { jest, describe, it, expect, beforeEach } from "@jest/globals"; import { describe, expect, it, beforeEach, vi } from "vitest";
import type { import type {
IDockerComposeLogOptions, IDockerComposeLogOptions,
IDockerComposeOptions, IDockerComposeOptions,
IDockerComposeResult, IDockerComposeResult,
} from "docker-compose"; } from "docker-compose";
// Mock docker-compose before importing the module under test // Mock docker-compose before importing the module under test
const upAllMock = jest.fn<(options: IDockerComposeOptions) => Promise<IDockerComposeResult>>(); const upAllMock =
vi.fn<(options: IDockerComposeOptions) => Promise<IDockerComposeResult>>();
const upManyMock = const upManyMock =
jest.fn<(services: string[], options: IDockerComposeOptions) => Promise<IDockerComposeResult>>(); vi.fn<
const downMock = jest.fn<(options: IDockerComposeOptions) => Promise<IDockerComposeResult>>(); (
services: string[],
options: IDockerComposeOptions,
) => Promise<IDockerComposeResult>
>();
const downMock =
vi.fn<(options: IDockerComposeOptions) => Promise<IDockerComposeResult>>();
const logsMock = const logsMock =
jest.fn< vi.fn<
(services: string[], options: IDockerComposeLogOptions) => Promise<IDockerComposeResult> (
>(); services: string[],
options: IDockerComposeLogOptions,
) => Promise<IDockerComposeResult>
>();
jest.unstable_mockModule("docker-compose", () => ({ vi.doMock("docker-compose", () => ({
upAll: upAllMock, upAll: upAllMock,
upMany: upManyMock, upMany: upManyMock,
down: downMock, down: downMock,
logs: logsMock, logs: logsMock,
})); }));
// Dynamic import after mock setup // Dynamic import after mock setup
const { DockerComposeService } = await import("./docker-compose.service.js"); const { DockerComposeService } = await import("./docker-compose.service.js");
describe("DockerComposeService", () => { describe("DockerComposeService", () => {
let service: InstanceType<typeof DockerComposeService>; let service: InstanceType<typeof DockerComposeService>;
beforeEach(() => { beforeEach(() => {
jest.clearAllMocks(); vi.clearAllMocks();
service = new DockerComposeService(); service = new DockerComposeService();
}); });
describe("up", () => { describe("up", () => {
it("should call up with correct options", async () => { it("should call up with correct options", async () => {
const upInputs = { const upInputs = {
dockerFlags: [] as string[], dockerFlags: [] as string[],
composeFiles: ["docker-compose.yml"], composeFiles: ["docker-compose.yml"],
services: [] as string[], services: [] as string[],
composeFlags: [] as string[], composeFlags: [] as string[],
upFlags: [] as string[], upFlags: [] as string[],
cwd: "/current/working/dir", cwd: "/current/working/dir",
serviceLogger: jest.fn(), serviceLogger: vi.fn(),
}; };
upAllMock.mockResolvedValue({ exitCode: 0, err: "", out: "" }); upAllMock.mockResolvedValue({ exitCode: 0, err: "", out: "" });
await service.up(upInputs); await service.up(upInputs);
expect(upAllMock).toHaveBeenCalledWith({ expect(upAllMock).toHaveBeenCalledWith({
composeOptions: [], composeOptions: [],
commandOptions: [], commandOptions: [],
config: ["docker-compose.yml"], config: ["docker-compose.yml"],
executable: { executable: {
executablePath: "docker", executablePath: "docker",
options: [], options: [],
}, },
cwd: "/current/working/dir", cwd: "/current/working/dir",
callback: expect.any(Function), callback: expect.any(Function),
}); });
// Ensure callback is calling the service logger // Ensure callback is calling the service logger
const callback = (upAllMock.mock.calls[0][0] as IDockerComposeOptions)?.callback; const callback = (upAllMock.mock.calls[0][0] as IDockerComposeOptions)
expect(callback).toBeDefined(); ?.callback;
expect(callback).toBeDefined();
const message = "test log output"; const message = "test log output";
if (callback) { if (callback) {
callback(Buffer.from(message)); callback(Buffer.from(message));
} }
expect(upInputs.serviceLogger).toHaveBeenCalledWith("test log output"); expect(upInputs.serviceLogger).toHaveBeenCalledWith("test log output");
}); });
it("should call up with specific docker flags", async () => { it("should call up with specific docker flags", async () => {
const upInputs = { const upInputs = {
dockerFlags: ["--context", "dev"], dockerFlags: ["--context", "dev"],
composeFiles: ["docker-compose.yml"], composeFiles: ["docker-compose.yml"],
services: [] as string[], services: [] as string[],
composeFlags: [] as string[], composeFlags: [] as string[],
upFlags: [] as string[], upFlags: [] as string[],
cwd: "/current/working/dir", cwd: "/current/working/dir",
serviceLogger: jest.fn(), serviceLogger: vi.fn(),
}; };
upAllMock.mockResolvedValue({ exitCode: 0, err: "", out: "" }); upAllMock.mockResolvedValue({ exitCode: 0, err: "", out: "" });
await service.up(upInputs); await service.up(upInputs);
expect(upAllMock).toHaveBeenCalledWith({ expect(upAllMock).toHaveBeenCalledWith({
composeOptions: [], composeOptions: [],
commandOptions: [], commandOptions: [],
config: ["docker-compose.yml"], config: ["docker-compose.yml"],
executable: { executable: {
executablePath: "docker", executablePath: "docker",
options: ["--context", "dev"], options: ["--context", "dev"],
}, },
cwd: "/current/working/dir", cwd: "/current/working/dir",
callback: expect.any(Function), callback: expect.any(Function),
}); });
}); });
it("should call up with specific services", async () => { it("should call up with specific services", async () => {
const upInputs = { const upInputs = {
dockerFlags: [] as string[], dockerFlags: [] as string[],
composeFiles: ["docker-compose.yml"], composeFiles: ["docker-compose.yml"],
services: ["helloworld2", "helloworld3"], services: ["helloworld2", "helloworld3"],
composeFlags: [] as string[], composeFlags: [] as string[],
upFlags: ["--build"], upFlags: ["--build"],
cwd: "/current/working/dir", cwd: "/current/working/dir",
serviceLogger: jest.fn(), serviceLogger: vi.fn(),
}; };
upManyMock.mockResolvedValue({ exitCode: 0, err: "", out: "" }); upManyMock.mockResolvedValue({ exitCode: 0, err: "", out: "" });
await service.up(upInputs); await service.up(upInputs);
expect(upManyMock).toHaveBeenCalledWith(["helloworld2", "helloworld3"], { expect(upManyMock).toHaveBeenCalledWith(["helloworld2", "helloworld3"], {
composeOptions: [], composeOptions: [],
commandOptions: ["--build"], commandOptions: ["--build"],
config: ["docker-compose.yml"], config: ["docker-compose.yml"],
cwd: "/current/working/dir", cwd: "/current/working/dir",
callback: expect.any(Function), callback: expect.any(Function),
executable: { executable: {
executablePath: "docker", executablePath: "docker",
options: [], options: [],
}, },
}); });
}); });
it("should throw formatted error when upAll fails with docker-compose result", async () => { it("should throw formatted error when upAll fails with docker-compose result", async () => {
const upInputs = { const upInputs = {
dockerFlags: [] as string[], dockerFlags: [] as string[],
composeFiles: ["docker-compose.yml"], composeFiles: ["docker-compose.yml"],
services: [] as string[], services: [] as string[],
composeFlags: [] as string[], composeFlags: [] as string[],
upFlags: [] as string[], upFlags: [] as string[],
cwd: "/current/working/dir", cwd: "/current/working/dir",
serviceLogger: jest.fn(), serviceLogger: vi.fn(),
}; };
const dockerComposeError = { const dockerComposeError = {
exitCode: 1, exitCode: 1,
err: "Error: unable to pull image\nfailed to resolve reference", err: "Error: unable to pull image\nfailed to resolve reference",
out: "", out: "",
}; };
upAllMock.mockRejectedValue(dockerComposeError); upAllMock.mockRejectedValue(dockerComposeError);
await expect(service.up(upInputs)).rejects.toThrow( await expect(service.up(upInputs)).rejects.toThrow(
"Docker Compose command failed with exit code 1" "Docker Compose command failed with exit code 1",
); );
await expect(service.up(upInputs)).rejects.toThrow("unable to pull image"); await expect(service.up(upInputs)).rejects.toThrow(
}); "unable to pull image",
);
});
it("should throw formatted error when upMany fails with docker-compose result", async () => { it("should throw formatted error when upMany fails with docker-compose result", async () => {
const upInputs = { const upInputs = {
dockerFlags: [] as string[], dockerFlags: [] as string[],
composeFiles: ["docker-compose.yml"], composeFiles: ["docker-compose.yml"],
services: ["web"], services: ["web"],
composeFlags: [] as string[], composeFlags: [] as string[],
upFlags: [] as string[], upFlags: [] as string[],
cwd: "/current/working/dir", cwd: "/current/working/dir",
serviceLogger: jest.fn(), serviceLogger: vi.fn(),
}; };
const dockerComposeError = { const dockerComposeError = {
exitCode: 1, exitCode: 1,
err: "Service 'web' failed to start", err: "Service 'web' failed to start",
out: "Starting web...", out: "Starting web...",
}; };
upManyMock.mockRejectedValue(dockerComposeError); upManyMock.mockRejectedValue(dockerComposeError);
await expect(service.up(upInputs)).rejects.toThrow( await expect(service.up(upInputs)).rejects.toThrow(
"Docker Compose command failed with exit code 1" "Docker Compose command failed with exit code 1",
); );
await expect(service.up(upInputs)).rejects.toThrow("Service 'web' failed to start"); await expect(service.up(upInputs)).rejects.toThrow(
await expect(service.up(upInputs)).rejects.toThrow("Starting web..."); "Service 'web' failed to start",
}); );
await expect(service.up(upInputs)).rejects.toThrow("Starting web...");
});
it("should pass through docker-compose result without exit code", async () => { it("should pass through docker-compose result without exit code", async () => {
const upInputs = { const upInputs = {
dockerFlags: [] as string[], dockerFlags: [] as string[],
composeFiles: ["docker-compose.yml"], composeFiles: ["docker-compose.yml"],
services: [] as string[], services: [] as string[],
composeFlags: [] as string[], composeFlags: [] as string[],
upFlags: [] as string[], upFlags: [] as string[],
cwd: "/current/working/dir", cwd: "/current/working/dir",
serviceLogger: jest.fn(), serviceLogger: vi.fn(),
}; };
const dockerComposeError = { const dockerComposeError = {
exitCode: null, exitCode: null,
err: "Some error without exit code", err: "Some error without exit code",
out: "", out: "",
}; };
upAllMock.mockRejectedValue(dockerComposeError); upAllMock.mockRejectedValue(dockerComposeError);
await expect(service.up(upInputs)).rejects.toThrow("Some error without exit code"); await expect(service.up(upInputs)).rejects.toThrow(
}); "Some error without exit code",
);
});
it("should pass through standard Error objects", async () => { it("should format docker-compose result when streams are undefined", async () => {
const upInputs = { const upInputs = {
dockerFlags: [] as string[], dockerFlags: [] as string[],
composeFiles: ["docker-compose.yml"], composeFiles: ["docker-compose.yml"],
services: [] as string[], services: [] as string[],
composeFlags: [] as string[], composeFlags: [] as string[],
upFlags: [] as string[], upFlags: [] as string[],
cwd: "/current/working/dir", cwd: "/current/working/dir",
serviceLogger: jest.fn(), serviceLogger: vi.fn(),
}; };
const standardError = new Error("Standard error message"); const dockerComposeError = {
upAllMock.mockRejectedValue(standardError); exitCode: 1,
err: undefined,
out: undefined,
};
await expect(service.up(upInputs)).rejects.toThrow("Standard error message"); upAllMock.mockRejectedValue(dockerComposeError);
});
it("should pass through error strings", async () => { await expect(service.up(upInputs)).rejects.toThrow(
const upInputs = { "Docker Compose command failed with exit code 1",
dockerFlags: [] as string[], );
composeFiles: ["docker-compose.yml"], await expect(service.up(upInputs)).rejects.not.toThrow("Error output:");
services: [] as string[], await expect(service.up(upInputs)).rejects.not.toThrow(
composeFlags: [] as string[], "Standard output:",
upFlags: [] as string[], );
cwd: "/current/working/dir", });
serviceLogger: jest.fn(),
};
const unknownError = "Some unknown error"; it("should pass through standard Error objects", async () => {
upAllMock.mockRejectedValue(unknownError); const upInputs = {
dockerFlags: [] as string[],
composeFiles: ["docker-compose.yml"],
services: [] as string[],
composeFlags: [] as string[],
upFlags: [] as string[],
cwd: "/current/working/dir",
serviceLogger: vi.fn(),
};
await expect(service.up(upInputs)).rejects.toThrow("Some unknown error"); const standardError = new Error("Standard error message");
}); upAllMock.mockRejectedValue(standardError);
it("should handle unknown error types gracefully", async () => { await expect(service.up(upInputs)).rejects.toThrow(
const upInputs = { "Standard error message",
dockerFlags: [] as string[], );
composeFiles: ["docker-compose.yml"], });
services: [] as string[],
composeFlags: [] as string[],
upFlags: [] as string[],
cwd: "/current/working/dir",
serviceLogger: jest.fn(),
};
const unknownError = { unexpected: "error format" }; it("should pass through error strings", async () => {
upAllMock.mockRejectedValue(unknownError); const upInputs = {
dockerFlags: [] as string[],
composeFiles: ["docker-compose.yml"],
services: [] as string[],
composeFlags: [] as string[],
upFlags: [] as string[],
cwd: "/current/working/dir",
serviceLogger: vi.fn(),
};
await expect(service.up(upInputs)).rejects.toThrow(JSON.stringify(unknownError)); const unknownError = "Some unknown error";
}); upAllMock.mockRejectedValue(unknownError);
});
describe("down", () => { await expect(service.up(upInputs)).rejects.toThrow("Some unknown error");
it("should call down with correct options", async () => { });
const downInputs = {
dockerFlags: [] as string[],
composeFiles: [] as string[],
composeFlags: [] as string[],
downFlags: ["--volumes", "--remove-orphans"],
cwd: "/current/working/dir",
serviceLogger: jest.fn(),
};
downMock.mockResolvedValue({ exitCode: 0, err: "", out: "" }); it("should handle unknown error types gracefully", async () => {
const upInputs = {
dockerFlags: [] as string[],
composeFiles: ["docker-compose.yml"],
services: [] as string[],
composeFlags: [] as string[],
upFlags: [] as string[],
cwd: "/current/working/dir",
serviceLogger: vi.fn(),
};
await service.down(downInputs); const unknownError = { unexpected: "error format" };
upAllMock.mockRejectedValue(unknownError);
expect(downMock).toHaveBeenCalledWith({ await expect(service.up(upInputs)).rejects.toThrow(
composeOptions: [], JSON.stringify(unknownError),
commandOptions: ["--volumes", "--remove-orphans"], );
config: [], });
executable: { });
executablePath: "docker",
options: [],
},
cwd: "/current/working/dir",
callback: expect.any(Function),
});
});
it("should throw formatted error when down fails with docker-compose result", async () => { describe("down", () => {
const downInputs = { it("should call down with correct options", async () => {
dockerFlags: [] as string[], const downInputs = {
composeFiles: [] as string[], dockerFlags: [] as string[],
composeFlags: [] as string[], composeFiles: [] as string[],
downFlags: [] as string[], composeFlags: [] as string[],
cwd: "/current/working/dir", downFlags: ["--volumes", "--remove-orphans"],
serviceLogger: jest.fn(), cwd: "/current/working/dir",
}; serviceLogger: vi.fn(),
};
const dockerComposeError = { downMock.mockResolvedValue({ exitCode: 0, err: "", out: "" });
exitCode: 1,
err: "Error stopping containers",
out: "",
};
downMock.mockRejectedValue(dockerComposeError); await service.down(downInputs);
await expect(service.down(downInputs)).rejects.toThrow( expect(downMock).toHaveBeenCalledWith({
"Docker Compose command failed with exit code 1" composeOptions: [],
); commandOptions: ["--volumes", "--remove-orphans"],
await expect(service.down(downInputs)).rejects.toThrow("Error stopping containers"); config: [],
}); executable: {
}); executablePath: "docker",
options: [],
},
cwd: "/current/working/dir",
callback: expect.any(Function),
});
});
describe("logs", () => { it("should throw formatted error when down fails with docker-compose result", async () => {
it("should call logs with correct options", async () => { const downInputs = {
const debugMock = jest.fn(); dockerFlags: [] as string[],
const logsInputs = { composeFiles: [] as string[],
dockerFlags: [] as string[], composeFlags: [] as string[],
composeFiles: ["docker-compose.yml"], downFlags: [] as string[],
services: ["helloworld2", "helloworld3"], cwd: "/current/working/dir",
composeFlags: [] as string[], serviceLogger: vi.fn(),
cwd: "/current/working/dir", };
serviceLogger: debugMock,
};
logsMock.mockResolvedValue({ exitCode: 0, err: "", out: "logs" }); const dockerComposeError = {
exitCode: 1,
err: "Error stopping containers",
out: "",
};
await service.logs(logsInputs); downMock.mockRejectedValue(dockerComposeError);
expect(logsMock).toHaveBeenCalledWith(["helloworld2", "helloworld3"], { await expect(service.down(downInputs)).rejects.toThrow(
composeOptions: [], "Docker Compose command failed with exit code 1",
config: ["docker-compose.yml"], );
cwd: "/current/working/dir", await expect(service.down(downInputs)).rejects.toThrow(
executable: { "Error stopping containers",
executablePath: "docker", );
options: [], });
}, });
follow: false,
callback: expect.any(Function), describe("logs", () => {
}); it("should call logs with correct options", async () => {
}); const debugMock = vi.fn();
}); const logsInputs = {
dockerFlags: [] as string[],
composeFiles: ["docker-compose.yml"],
services: ["helloworld2", "helloworld3"],
composeFlags: [] as string[],
cwd: "/current/working/dir",
serviceLogger: debugMock,
};
logsMock.mockResolvedValue({ exitCode: 0, err: "", out: "logs" });
await service.logs(logsInputs);
expect(logsMock).toHaveBeenCalledWith(["helloworld2", "helloworld3"], {
composeOptions: [],
config: ["docker-compose.yml"],
cwd: "/current/working/dir",
executable: {
executablePath: "docker",
options: [],
},
follow: false,
callback: expect.any(Function),
});
});
});
}); });

View File

@ -1,148 +1,153 @@
import { import {
down, down,
IDockerComposeLogOptions, type IDockerComposeLogOptions,
IDockerComposeOptions, type IDockerComposeOptions,
IDockerComposeResult, type IDockerComposeResult,
logs, logs,
upAll, upAll,
upMany, upMany,
} from "docker-compose"; } from "docker-compose";
import { Inputs } from "./input.service.js"; import type { Inputs } from "./input.service.js";
type OptionsInputs = { type OptionsInputs = {
dockerFlags: Inputs["dockerFlags"]; dockerFlags: Inputs["dockerFlags"];
composeFiles: Inputs["composeFiles"]; composeFiles: Inputs["composeFiles"];
composeFlags: Inputs["composeFlags"]; composeFlags: Inputs["composeFlags"];
cwd: Inputs["cwd"]; cwd: Inputs["cwd"];
serviceLogger: (message: string) => void; serviceLogger: (message: string) => void;
}; };
export type UpInputs = OptionsInputs & { upFlags: Inputs["upFlags"]; services: Inputs["services"] }; export type UpInputs = OptionsInputs & {
upFlags: Inputs["upFlags"];
services: Inputs["services"];
};
export type DownInputs = OptionsInputs & { downFlags: Inputs["downFlags"] }; export type DownInputs = OptionsInputs & { downFlags: Inputs["downFlags"] };
export type LogsInputs = OptionsInputs & { services: Inputs["services"] }; export type LogsInputs = OptionsInputs & { services: Inputs["services"] };
export class DockerComposeService { export class DockerComposeService {
async up({ upFlags, services, ...optionsInputs }: UpInputs): Promise<void> { async up({ upFlags, services, ...optionsInputs }: UpInputs): Promise<void> {
const options: IDockerComposeOptions = { const options: IDockerComposeOptions = {
...this.getCommonOptions(optionsInputs), ...this.getCommonOptions(optionsInputs),
commandOptions: upFlags, commandOptions: upFlags,
}; };
try { try {
if (services.length > 0) { if (services.length > 0) {
await upMany(services, options); await upMany(services, options);
return; return;
} }
await upAll(options); await upAll(options);
} catch (error) { } catch (error) {
throw this.formatDockerComposeError(error); throw this.formatDockerComposeError(error);
} }
} }
async down({ downFlags, ...optionsInputs }: DownInputs): Promise<void> { async down({ downFlags, ...optionsInputs }: DownInputs): Promise<void> {
const options: IDockerComposeOptions = { const options: IDockerComposeOptions = {
...this.getCommonOptions(optionsInputs), ...this.getCommonOptions(optionsInputs),
commandOptions: downFlags, commandOptions: downFlags,
}; };
try { try {
await down(options); await down(options);
} catch (error) { } catch (error) {
throw this.formatDockerComposeError(error); throw this.formatDockerComposeError(error);
} }
} }
async logs({ services, ...optionsInputs }: LogsInputs): Promise<{ async logs({ services, ...optionsInputs }: LogsInputs): Promise<{
error: string; error: string;
output: string; output: string;
}> { }> {
const options: IDockerComposeLogOptions = { const options: IDockerComposeLogOptions = {
...this.getCommonOptions(optionsInputs), ...this.getCommonOptions(optionsInputs),
follow: false, follow: false,
}; };
const { err, out } = await logs(services, options); const { err, out } = await logs(services, options);
return { return {
error: err, error: err,
output: out, output: out,
}; };
} }
private getCommonOptions({ private getCommonOptions({
dockerFlags, dockerFlags,
composeFiles, composeFiles,
composeFlags, composeFlags,
cwd, cwd,
serviceLogger, serviceLogger,
}: OptionsInputs): IDockerComposeOptions { }: OptionsInputs): IDockerComposeOptions {
return { return {
config: composeFiles, config: composeFiles,
composeOptions: composeFlags, composeOptions: composeFlags,
cwd: cwd, cwd: cwd,
callback: (chunk) => serviceLogger(chunk.toString()), callback: (chunk) => serviceLogger(chunk.toString()),
executable: { executable: {
executablePath: "docker", executablePath: "docker",
options: dockerFlags, options: dockerFlags,
}, },
}; };
} }
/** /**
* Formats docker-compose errors into proper Error objects with readable messages * Formats docker-compose errors into proper Error objects with readable messages
*/ */
private formatDockerComposeError(error: unknown): Error { private formatDockerComposeError(error: unknown): Error {
// If it's already an Error, return it // If it's already an Error, return it
if (error instanceof Error) { if (error instanceof Error) {
return error; return error;
} }
// Handle docker-compose result objects // Handle docker-compose result objects
if (this.isDockerComposeResult(error)) { if (this.isDockerComposeResult(error)) {
const parts: string[] = []; const parts: string[] = [];
// Add exit code information // Add exit code information
if (error.exitCode !== null) { if (error.exitCode !== null) {
parts.push(`Docker Compose command failed with exit code ${error.exitCode}`); parts.push(
} else { `Docker Compose command failed with exit code ${error.exitCode}`,
parts.push("Docker Compose command failed"); );
} } else {
parts.push("Docker Compose command failed");
}
// Add error stream output if available // Add error stream output if available
if (error.err && error.err.trim()) { if (error.err?.trim()) {
parts.push("\nError output:"); parts.push("\nError output:");
parts.push(error.err.trim()); parts.push(error.err.trim());
} }
// Add standard output if available and different from error output // Add standard output if available and different from error output
if (error.out && error.out.trim() && error.out !== error.err) { if (error.out?.trim() && error.out !== error.err) {
parts.push("\nStandard output:"); parts.push("\nStandard output:");
parts.push(error.out.trim()); parts.push(error.out.trim());
} }
return new Error(parts.join("\n")); return new Error(parts.join("\n"));
} }
// Handle string errors // Handle string errors
if (typeof error === "string") { if (typeof error === "string") {
return new Error(error); return new Error(error);
} }
// Fallback for unknown error types // Fallback for unknown error types
return new Error(JSON.stringify(error)); return new Error(JSON.stringify(error));
} }
/** /**
* Type guard to check if an object is a docker-compose result * Type guard to check if an object is a docker-compose result
*/ */
private isDockerComposeResult(error: unknown): error is IDockerComposeResult { private isDockerComposeResult(error: unknown): error is IDockerComposeResult {
return ( return (
typeof error === "object" && typeof error === "object" &&
error !== null && error !== null &&
"exitCode" in error && "exitCode" in error &&
"err" in error && "err" in error &&
"out" in error "out" in error
); );
} }
} }

View File

@ -1,24 +1,25 @@
import { jest, describe, it, expect, beforeEach } from "@jest/globals"; import { describe, expect, it, beforeEach, vi } from "vitest";
// Mock @actions/core before importing the module under test // Mock @actions/core before importing the module under test
const getInputMock = jest.fn<(name: string, options?: { required?: boolean }) => string>(); const getInputMock =
vi.fn<(name: string, options?: { required?: boolean }) => string>();
const getMultilineInputMock = const getMultilineInputMock =
jest.fn<(name: string, options?: { required?: boolean }) => string[]>(); vi.fn<(name: string, options?: { required?: boolean }) => string[]>();
jest.unstable_mockModule("@actions/core", () => ({ vi.doMock("@actions/core", () => ({
getInput: getInputMock, getInput: getInputMock,
getMultilineInput: getMultilineInputMock, getMultilineInput: getMultilineInputMock,
debug: jest.fn(), debug: vi.fn(),
info: jest.fn(), info: vi.fn(),
warning: jest.fn(), warning: vi.fn(),
})); }));
// Mock node:fs // Mock node:fs
const existsSyncMock = jest.fn<(path: string) => boolean>(); const existsSyncMock = vi.fn<(path: string) => boolean>();
jest.unstable_mockModule("node:fs", () => ({ vi.doMock("node:fs", () => ({
existsSync: existsSyncMock, existsSync: existsSyncMock,
default: { existsSync: existsSyncMock }, default: { existsSync: existsSyncMock },
})); }));
// Dynamic imports after mock setup // Dynamic imports after mock setup
@ -26,369 +27,373 @@ const { InputService, InputNames } = await import("./input.service.js");
const { LogLevel } = await import("./logger.service.js"); const { LogLevel } = await import("./logger.service.js");
describe("InputService", () => { describe("InputService", () => {
let service: InstanceType<typeof InputService>; let service: InstanceType<typeof InputService>;
beforeEach(() => { beforeEach(() => {
jest.clearAllMocks(); vi.clearAllMocks();
getMultilineInputMock.mockImplementation((inputName) => { getMultilineInputMock.mockImplementation((inputName) => {
switch (inputName) { switch (inputName) {
case InputNames.ComposeFile: case InputNames.ComposeFile:
return ["file1"]; return ["file1"];
default: default:
return []; return [];
} }
}); });
getInputMock.mockReturnValue(""); getInputMock.mockReturnValue("");
service = new InputService(); service = new InputService();
}); });
describe("getInputs", () => { describe("getInputs", () => {
describe("docker-flags", () => { describe("docker-flags", () => {
it("should return given docker-flags input", () => { it("should return given docker-flags input", () => {
getInputMock.mockImplementation((inputName) => { getInputMock.mockImplementation((inputName) => {
switch (inputName) { switch (inputName) {
case InputNames.DockerFlags: case InputNames.DockerFlags:
return "docker-flag1 docker-flag2"; return "docker-flag1 docker-flag2";
default: default:
return ""; return "";
} }
}); });
existsSyncMock.mockReturnValue(true); existsSyncMock.mockReturnValue(true);
const inputs = service.getInputs(); const inputs = service.getInputs();
expect(inputs.dockerFlags).toEqual(["docker-flag1", "docker-flag2"]); expect(inputs.dockerFlags).toEqual(["docker-flag1", "docker-flag2"]);
}); });
it("should return empty array when no docker-flags input", () => { it("should return empty array when no docker-flags input", () => {
getInputMock.mockReturnValue(""); getInputMock.mockReturnValue("");
existsSyncMock.mockReturnValue(true); existsSyncMock.mockReturnValue(true);
const inputs = service.getInputs(); const inputs = service.getInputs();
expect(inputs.dockerFlags).toEqual([]); expect(inputs.dockerFlags).toEqual([]);
}); });
}); });
describe("composeFiles", () => { describe("composeFiles", () => {
it("should return given composeFiles input", () => { it("should return given composeFiles input", () => {
getMultilineInputMock.mockImplementation((inputName) => { getMultilineInputMock.mockImplementation((inputName) => {
switch (inputName) { switch (inputName) {
case InputNames.ComposeFile: case InputNames.ComposeFile:
return ["file1", "file2"]; return ["file1", "file2"];
default: default:
return []; return [];
} }
}); });
getInputMock.mockReturnValue(""); getInputMock.mockReturnValue("");
existsSyncMock.mockReturnValue(true); existsSyncMock.mockReturnValue(true);
const inputs = service.getInputs(); const inputs = service.getInputs();
expect(inputs.composeFiles).toEqual(["file1", "file2"]); expect(inputs.composeFiles).toEqual(["file1", "file2"]);
}); });
it("should ignore empty compose file entries", () => { it("should ignore empty compose file entries", () => {
getMultilineInputMock.mockImplementation((inputName) => { getMultilineInputMock.mockImplementation((inputName) => {
switch (inputName) { switch (inputName) {
case InputNames.ComposeFile: case InputNames.ComposeFile:
return [" ", "file1"]; return [" ", "file1"];
default: default:
return []; return [];
} }
}); });
getInputMock.mockReturnValue(""); getInputMock.mockReturnValue("");
existsSyncMock.mockReturnValue(true); existsSyncMock.mockReturnValue(true);
const inputs = service.getInputs(); const inputs = service.getInputs();
expect(inputs.composeFiles).toEqual(["file1"]); expect(inputs.composeFiles).toEqual(["file1"]);
}); });
it("should accept compose file when it exists at the original path", () => { it("should accept compose file when it exists at the original path", () => {
getMultilineInputMock.mockImplementation((inputName) => { getMultilineInputMock.mockImplementation((inputName) => {
switch (inputName) { switch (inputName) {
case InputNames.ComposeFile: case InputNames.ComposeFile:
return ["./compose.yml"]; return ["./compose.yml"];
default: default:
return []; return [];
} }
}); });
getInputMock.mockImplementation((inputName) => { getInputMock.mockImplementation((inputName) => {
switch (inputName) { switch (inputName) {
case InputNames.Cwd: case InputNames.Cwd:
return "/current/working/directory"; return "/current/working/directory";
default: default:
return ""; return "";
} }
}); });
existsSyncMock.mockImplementation((file) => file === "./compose.yml"); existsSyncMock.mockImplementation((file) => file === "./compose.yml");
const inputs = service.getInputs(); const inputs = service.getInputs();
expect(inputs.composeFiles).toEqual(["./compose.yml"]); expect(inputs.composeFiles).toEqual(["./compose.yml"]);
}); });
it("should accept OCI compose files without checking the file system", () => { it("should accept OCI compose files without checking the file system", () => {
getMultilineInputMock.mockImplementation((inputName) => { getMultilineInputMock.mockImplementation((inputName) => {
switch (inputName) { switch (inputName) {
case InputNames.ComposeFile: case InputNames.ComposeFile:
return ["oci://docker.io/hoverkraft/compose-app:latest"]; return ["oci://docker.io/hoverkraft/compose-app:latest"];
default: default:
return []; return [];
} }
}); });
getInputMock.mockImplementation((inputName) => { getInputMock.mockImplementation((inputName) => {
switch (inputName) { switch (inputName) {
case InputNames.Cwd: case InputNames.Cwd:
return "/current/working/directory"; return "/current/working/directory";
default: default:
return ""; return "";
} }
}); });
const inputs = service.getInputs(); const inputs = service.getInputs();
expect(inputs.composeFiles).toEqual(["oci://docker.io/hoverkraft/compose-app:latest"]); expect(inputs.composeFiles).toEqual([
expect(existsSyncMock).not.toHaveBeenCalled(); "oci://docker.io/hoverkraft/compose-app:latest",
}); ]);
expect(existsSyncMock).not.toHaveBeenCalled();
it("should throws an error when a compose file does not exist", () => { });
getMultilineInputMock.mockImplementation((inputName) => {
switch (inputName) { it("should throws an error when a compose file does not exist", () => {
case InputNames.ComposeFile: getMultilineInputMock.mockImplementation((inputName) => {
return ["file1", "file2"]; switch (inputName) {
default: case InputNames.ComposeFile:
return []; return ["file1", "file2"];
} default:
}); return [];
}
getInputMock.mockImplementation((inputName) => { });
switch (inputName) {
case InputNames.Cwd: getInputMock.mockImplementation((inputName) => {
return "/current/working/directory"; switch (inputName) {
default: case InputNames.Cwd:
return ""; return "/current/working/directory";
} default:
}); return "";
}
existsSyncMock.mockImplementation((file) => file === "/current/working/directory/file1"); });
expect(() => service.getInputs()).toThrow( existsSyncMock.mockImplementation(
'Compose file not found in "/current/working/directory/file2", "file2"' (file) => file === "/current/working/directory/file1",
); );
});
expect(() => service.getInputs()).toThrow(
it("should throws an error when no composeFiles input", () => { 'Compose file not found in "/current/working/directory/file2", "file2"',
getMultilineInputMock.mockReturnValue([]); );
});
getInputMock.mockReturnValue("");
it("should throws an error when no composeFiles input", () => {
expect(() => service.getInputs()).toThrow("No compose files found"); getMultilineInputMock.mockReturnValue([]);
});
}); getInputMock.mockReturnValue("");
describe("services", () => { expect(() => service.getInputs()).toThrow("No compose files found");
it("should return given services input", () => { });
getMultilineInputMock.mockImplementation((inputName) => { });
switch (inputName) {
case InputNames.Services: describe("services", () => {
return ["service1", "service2"]; it("should return given services input", () => {
case InputNames.ComposeFile: getMultilineInputMock.mockImplementation((inputName) => {
return ["file1"]; switch (inputName) {
default: case InputNames.Services:
return []; return ["service1", "service2"];
} case InputNames.ComposeFile:
}); return ["file1"];
default:
getInputMock.mockReturnValue(""); return [];
existsSyncMock.mockReturnValue(true); }
});
const inputs = service.getInputs();
getInputMock.mockReturnValue("");
expect(inputs.services).toEqual(["service1", "service2"]); existsSyncMock.mockReturnValue(true);
});
}); const inputs = service.getInputs();
describe("compose-flags", () => { expect(inputs.services).toEqual(["service1", "service2"]);
it("should return given compose-flags input", () => { });
getInputMock.mockImplementation((inputName) => { });
switch (inputName) {
case InputNames.ComposeFlags: describe("compose-flags", () => {
return "compose-flag1 compose-flag2"; it("should return given compose-flags input", () => {
default: getInputMock.mockImplementation((inputName) => {
return ""; switch (inputName) {
} case InputNames.ComposeFlags:
}); return "compose-flag1 compose-flag2";
default:
existsSyncMock.mockReturnValue(true); return "";
}
const inputs = service.getInputs(); });
expect(inputs.composeFlags).toEqual(["compose-flag1", "compose-flag2"]); existsSyncMock.mockReturnValue(true);
});
const inputs = service.getInputs();
it("should return empty array when no compose-flags input", () => {
getInputMock.mockReturnValue(""); expect(inputs.composeFlags).toEqual(["compose-flag1", "compose-flag2"]);
});
existsSyncMock.mockReturnValue(true);
it("should return empty array when no compose-flags input", () => {
const inputs = service.getInputs(); getInputMock.mockReturnValue("");
expect(inputs.composeFlags).toEqual([]); existsSyncMock.mockReturnValue(true);
});
}); const inputs = service.getInputs();
describe("up-flags", () => { expect(inputs.composeFlags).toEqual([]);
it("should return given up-flags input", () => { });
getInputMock.mockImplementation((inputName) => { });
switch (inputName) {
case InputNames.UpFlags: describe("up-flags", () => {
return "up-flag1 up-flag2"; it("should return given up-flags input", () => {
default: getInputMock.mockImplementation((inputName) => {
return ""; switch (inputName) {
} case InputNames.UpFlags:
}); return "up-flag1 up-flag2";
default:
existsSyncMock.mockReturnValue(true); return "";
}
const inputs = service.getInputs(); });
expect(inputs.upFlags).toEqual(["up-flag1", "up-flag2"]); existsSyncMock.mockReturnValue(true);
});
const inputs = service.getInputs();
it("should return empty array when no up-flags input", () => {
getInputMock.mockReturnValue(""); expect(inputs.upFlags).toEqual(["up-flag1", "up-flag2"]);
});
existsSyncMock.mockReturnValue(true);
it("should return empty array when no up-flags input", () => {
const inputs = service.getInputs(); getInputMock.mockReturnValue("");
expect(inputs.upFlags).toEqual([]); existsSyncMock.mockReturnValue(true);
});
}); const inputs = service.getInputs();
describe("down-flags", () => { expect(inputs.upFlags).toEqual([]);
it("should return given down-flags input", () => { });
getInputMock.mockImplementation((inputName) => { });
switch (inputName) {
case InputNames.DownFlags: describe("down-flags", () => {
return "down-flag1 down-flag2"; it("should return given down-flags input", () => {
default: getInputMock.mockImplementation((inputName) => {
return ""; switch (inputName) {
} case InputNames.DownFlags:
}); return "down-flag1 down-flag2";
default:
existsSyncMock.mockReturnValue(true); return "";
}
const inputs = service.getInputs(); });
expect(inputs.downFlags).toEqual(["down-flag1", "down-flag2"]); existsSyncMock.mockReturnValue(true);
});
const inputs = service.getInputs();
it("should return empty array when no down-flags input", () => {
getInputMock.mockReturnValue(""); expect(inputs.downFlags).toEqual(["down-flag1", "down-flag2"]);
existsSyncMock.mockReturnValue(true); });
const inputs = service.getInputs(); it("should return empty array when no down-flags input", () => {
getInputMock.mockReturnValue("");
expect(inputs.downFlags).toEqual([]); existsSyncMock.mockReturnValue(true);
});
}); const inputs = service.getInputs();
describe("cwd", () => { expect(inputs.downFlags).toEqual([]);
it("should return given cwd input", () => { });
getInputMock.mockImplementation((inputName) => { });
switch (inputName) {
case InputNames.Cwd: describe("cwd", () => {
return "cwd"; it("should return given cwd input", () => {
default: getInputMock.mockImplementation((inputName) => {
return ""; switch (inputName) {
} case InputNames.Cwd:
}); return "cwd";
existsSyncMock.mockReturnValue(true); default:
return "";
const inputs = service.getInputs(); }
});
expect(inputs.cwd).toEqual("cwd"); existsSyncMock.mockReturnValue(true);
});
}); const inputs = service.getInputs();
describe("compose-version", () => { expect(inputs.cwd).toEqual("cwd");
it("should return given compose-version input", () => { });
getInputMock.mockImplementation((inputName) => { });
switch (inputName) {
case InputNames.ComposeVersion: describe("compose-version", () => {
return "compose-version"; it("should return given compose-version input", () => {
default: getInputMock.mockImplementation((inputName) => {
return ""; switch (inputName) {
} case InputNames.ComposeVersion:
}); return "compose-version";
existsSyncMock.mockReturnValue(true); default:
return "";
const inputs = service.getInputs(); }
});
expect(inputs.composeVersion).toEqual("compose-version"); existsSyncMock.mockReturnValue(true);
});
}); const inputs = service.getInputs();
describe("services-log-level", () => { expect(inputs.composeVersion).toEqual("compose-version");
it("should return given services-log-level input", () => { });
getInputMock.mockImplementation((inputName) => { });
switch (inputName) {
case InputNames.ServiceLogLevel: describe("services-log-level", () => {
return "info"; it("should return given services-log-level input", () => {
default: getInputMock.mockImplementation((inputName) => {
return ""; switch (inputName) {
} case InputNames.ServiceLogLevel:
}); return "info";
existsSyncMock.mockReturnValue(true); default:
return "";
const inputs = service.getInputs(); }
expect(inputs.serviceLogLevel).toEqual(LogLevel.Info); });
}); existsSyncMock.mockReturnValue(true);
it("should return default services-log-level input", () => { const inputs = service.getInputs();
getInputMock.mockImplementation((inputName) => { expect(inputs.serviceLogLevel).toEqual(LogLevel.Info);
switch (inputName) { });
case InputNames.ServiceLogLevel:
return ""; it("should return default services-log-level input", () => {
default: getInputMock.mockImplementation((inputName) => {
return ""; switch (inputName) {
} case InputNames.ServiceLogLevel:
}); return "";
existsSyncMock.mockReturnValue(true); default:
return "";
const inputs = service.getInputs(); }
expect(inputs.serviceLogLevel).toEqual(LogLevel.Debug); });
}); existsSyncMock.mockReturnValue(true);
it("should throw an error when services-log-level input is invalid", () => { const inputs = service.getInputs();
getInputMock.mockImplementation((inputName) => { expect(inputs.serviceLogLevel).toEqual(LogLevel.Debug);
switch (inputName) { });
case InputNames.ServiceLogLevel:
return "invalid-log-level"; it("should throw an error when services-log-level input is invalid", () => {
default: getInputMock.mockImplementation((inputName) => {
return ""; switch (inputName) {
} case InputNames.ServiceLogLevel:
}); return "invalid-log-level";
existsSyncMock.mockReturnValue(true); default:
return "";
expect(() => service.getInputs()).toThrow( }
'Invalid service log level "invalid-log-level". Valid values are: debug, info' });
); existsSyncMock.mockReturnValue(true);
});
}); expect(() => service.getInputs()).toThrow(
}); 'Invalid service log level "invalid-log-level". Valid values are: debug, info',
);
});
});
});
}); });

View File

@ -4,135 +4,144 @@ import { join } from "node:path";
import { LogLevel } from "./logger.service.js"; import { LogLevel } from "./logger.service.js";
export type Inputs = { export type Inputs = {
dockerFlags: string[]; dockerFlags: string[];
composeFiles: string[]; composeFiles: string[];
services: string[]; services: string[];
composeFlags: string[]; composeFlags: string[];
upFlags: string[]; upFlags: string[];
downFlags: string[]; downFlags: string[];
cwd: string; cwd: string;
composeVersion: string | null; composeVersion: string | null;
githubToken: string | null; githubToken: string | null;
serviceLogLevel: LogLevel; serviceLogLevel: LogLevel;
}; };
export enum InputNames { export enum InputNames {
DockerFlags = "docker-flags", DockerFlags = "docker-flags",
ComposeFile = "compose-file", ComposeFile = "compose-file",
Services = "services", Services = "services",
ComposeFlags = "compose-flags", ComposeFlags = "compose-flags",
UpFlags = "up-flags", UpFlags = "up-flags",
DownFlags = "down-flags", DownFlags = "down-flags",
Cwd = "cwd", Cwd = "cwd",
ComposeVersion = "compose-version", ComposeVersion = "compose-version",
GithubToken = "github-token", GithubToken = "github-token",
ServiceLogLevel = "services-log-level", ServiceLogLevel = "services-log-level",
} }
export const COMPOSE_VERSION_LATEST = "latest"; export const COMPOSE_VERSION_LATEST = "latest";
export class InputService { export class InputService {
getInputs(): Inputs { getInputs(): Inputs {
return { return {
dockerFlags: this.getDockerFlags(), dockerFlags: this.getDockerFlags(),
composeFiles: this.getComposeFiles(), composeFiles: this.getComposeFiles(),
services: this.getServices(), services: this.getServices(),
composeFlags: this.getComposeFlags(), composeFlags: this.getComposeFlags(),
upFlags: this.getUpFlags(), upFlags: this.getUpFlags(),
downFlags: this.getDownFlags(), downFlags: this.getDownFlags(),
cwd: this.getCwd(), cwd: this.getCwd(),
composeVersion: this.getComposeVersion(), composeVersion: this.getComposeVersion(),
githubToken: this.getGithubToken(), githubToken: this.getGithubToken(),
serviceLogLevel: this.getServiceLogLevel(), serviceLogLevel: this.getServiceLogLevel(),
}; };
} }
private getDockerFlags(): string[] { private getDockerFlags(): string[] {
return this.parseFlags(getInput(InputNames.DockerFlags)); return this.parseFlags(getInput(InputNames.DockerFlags));
} }
private getComposeFiles(): string[] { private getComposeFiles(): string[] {
const cwd = this.getCwd(); const cwd = this.getCwd();
const composeFiles = getMultilineInput(InputNames.ComposeFile).filter((composeFile: string) => { const composeFiles = getMultilineInput(InputNames.ComposeFile).filter(
const trimmedComposeFile = composeFile.trim(); (composeFile: string) => {
const trimmedComposeFile = composeFile.trim();
if (!trimmedComposeFile.length) { if (!trimmedComposeFile.length) {
return false; return false;
} }
if (trimmedComposeFile.startsWith("oci://")) { if (trimmedComposeFile.startsWith("oci://")) {
return true; return true;
} }
const possiblePaths = [join(cwd, composeFile), composeFile]; const possiblePaths = [join(cwd, composeFile), composeFile];
for (const path of possiblePaths) { for (const path of possiblePaths) {
if (existsSync(path)) { if (existsSync(path)) {
return true; return true;
} }
} }
throw new Error(`Compose file not found in "${possiblePaths.join('", "')}"`); throw new Error(
}); `Compose file not found in "${possiblePaths.join('", "')}"`,
);
},
);
if (!composeFiles.length) { if (!composeFiles.length) {
throw new Error("No compose files found"); throw new Error("No compose files found");
} }
return composeFiles; return composeFiles;
} }
private getServices(): string[] { private getServices(): string[] {
return getMultilineInput(InputNames.Services, { required: false }); return getMultilineInput(InputNames.Services, { required: false });
} }
private getComposeFlags(): string[] { private getComposeFlags(): string[] {
return this.parseFlags(getInput(InputNames.ComposeFlags)); return this.parseFlags(getInput(InputNames.ComposeFlags));
} }
private getUpFlags(): string[] { private getUpFlags(): string[] {
return this.parseFlags(getInput(InputNames.UpFlags)); return this.parseFlags(getInput(InputNames.UpFlags));
} }
private getDownFlags(): string[] { private getDownFlags(): string[] {
return this.parseFlags(getInput(InputNames.DownFlags)); return this.parseFlags(getInput(InputNames.DownFlags));
} }
private parseFlags(flags: string | null): string[] { private parseFlags(flags: string | null): string[] {
if (!flags) { if (!flags) {
return []; return [];
} }
return flags.trim().split(" "); return flags.trim().split(" ");
} }
private getCwd(): string { private getCwd(): string {
return getInput(InputNames.Cwd); return getInput(InputNames.Cwd);
} }
private getComposeVersion(): string | null { private getComposeVersion(): string | null {
return ( return (
getInput(InputNames.ComposeVersion, { getInput(InputNames.ComposeVersion, {
required: false, required: false,
}) || null }) || null
); );
} }
private getGithubToken(): string | null { private getGithubToken(): string | null {
return ( return (
getInput(InputNames.GithubToken, { getInput(InputNames.GithubToken, {
required: false, required: false,
}) || null }) || null
); );
} }
private getServiceLogLevel(): LogLevel { private getServiceLogLevel(): LogLevel {
const configuredLevel = getInput(InputNames.ServiceLogLevel, { required: false }); const configuredLevel = getInput(InputNames.ServiceLogLevel, {
if (configuredLevel && !Object.values(LogLevel).includes(configuredLevel as LogLevel)) { required: false,
throw new Error( });
`Invalid service log level "${configuredLevel}". Valid values are: ${Object.values(LogLevel).join(", ")}` if (
); configuredLevel &&
} !Object.values(LogLevel).includes(configuredLevel as LogLevel)
return (configuredLevel as LogLevel) || LogLevel.Debug; ) {
} throw new Error(
`Invalid service log level "${configuredLevel}". Valid values are: ${Object.values(LogLevel).join(", ")}`,
);
}
return (configuredLevel as LogLevel) || LogLevel.Debug;
}
} }

View File

@ -1,3 +1,3 @@
export interface DockerComposeInstallerAdapter { export interface DockerComposeInstallerAdapter {
install(version: string): Promise<void>; install(version: string): Promise<void>;
} }

View File

@ -1,196 +1,221 @@
import { jest, describe, it, expect, beforeEach } from "@jest/globals"; import { describe, expect, it, beforeEach, vi } from "vitest";
import type { ExecOptions } from "@actions/exec"; import type { ExecOptions } from "@actions/exec";
import type { OutgoingHttpHeaders } from "node:http"; import type { OutgoingHttpHeaders } from "node:http";
// Mock @actions/exec // Mock @actions/exec
const execMock = const execMock =
jest.fn<(command: string, args?: string[], options?: ExecOptions) => Promise<number>>(); vi.fn<
(command: string, args?: string[], options?: ExecOptions) => Promise<number>
>();
jest.unstable_mockModule("@actions/exec", () => ({ vi.doMock("@actions/exec", () => ({
exec: execMock, exec: execMock,
})); }));
// Mock @actions/io // Mock @actions/io
const mkdirPMock = jest.fn<(fsPath: string) => Promise<void>>(); const mkdirPMock = vi.fn<(fsPath: string) => Promise<void>>();
jest.unstable_mockModule("@actions/io", () => ({ vi.doMock("@actions/io", () => ({
mkdirP: mkdirPMock, mkdirP: mkdirPMock,
})); }));
// Mock @actions/tool-cache // Mock @actions/tool-cache
const cacheFileMock = const cacheFileMock =
jest.fn< vi.fn<
( (
sourceFile: string, sourceFile: string,
targetFile: string, targetFile: string,
tool: string, tool: string,
version: string, version: string,
arch?: string arch?: string,
) => Promise<string> ) => Promise<string>
>(); >();
const downloadToolMock = const downloadToolMock =
jest.fn< vi.fn<
(url: string, dest?: string, auth?: string, headers?: OutgoingHttpHeaders) => Promise<string> (
>(); url: string,
dest?: string,
auth?: string,
headers?: OutgoingHttpHeaders,
) => Promise<string>
>();
jest.unstable_mockModule("@actions/tool-cache", () => ({ vi.doMock("@actions/tool-cache", () => ({
cacheFile: cacheFileMock, cacheFile: cacheFileMock,
downloadTool: downloadToolMock, downloadTool: downloadToolMock,
})); }));
// Dynamic import after mock setup // Dynamic import after mock setup
const { ManualInstallerAdapter } = await import("./manual-installer-adapter.js"); const { ManualInstallerAdapter } = await import(
"./manual-installer-adapter.js"
);
const originalHome = process.env.HOME;
const originalDockerConfig = process.env.DOCKER_CONFIG;
describe("ManualInstallerAdapter", () => { describe("ManualInstallerAdapter", () => {
let adapter: InstanceType<typeof ManualInstallerAdapter>; let adapter: InstanceType<typeof ManualInstallerAdapter>;
beforeEach(() => { beforeEach(() => {
jest.clearAllMocks(); vi.resetAllMocks();
delete process.env.DOCKER_CONFIG; if (originalHome === undefined) {
adapter = new ManualInstallerAdapter(); delete process.env.HOME;
}); } else {
process.env.HOME = originalHome;
}
if (originalDockerConfig === undefined) {
delete process.env.DOCKER_CONFIG;
} else {
process.env.DOCKER_CONFIG = originalDockerConfig;
}
adapter = new ManualInstallerAdapter();
});
describe("install", () => { describe("install", () => {
it("should install docker compose correctly", async () => { it("should install docker compose correctly", async () => {
// Arrange // Arrange
const version = "v2.29.0"; const version = "v2.29.0";
// Uname -s execMock.mockImplementationOnce(async (_command, _args, options) => {
execMock.mockResolvedValueOnce(0); options?.listeners?.stdout?.(Buffer.from("Linux\n"));
return 0;
});
// Uname -m execMock.mockImplementationOnce(async (_command, _args, options) => {
execMock.mockResolvedValueOnce(0); options?.listeners?.stdout?.(Buffer.from("x86_64\n"));
return 0;
});
Object.defineProperty(process.env, "HOME", { process.env.HOME = "/home/test";
value: "/home/test",
});
// Act // Act
await adapter.install(version); await adapter.install(version);
// Assert // Assert
expect(mkdirPMock).toHaveBeenCalledWith("docker-compose"); expect(mkdirPMock).toHaveBeenCalledWith("docker-compose");
expect(execMock).toHaveBeenNthCalledWith(1, "uname -s", [], { expect(execMock).toHaveBeenNthCalledWith(1, "uname -s", [], {
listeners: { stdout: expect.any(Function) }, listeners: { stdout: expect.any(Function) },
}); });
expect(execMock).toHaveBeenNthCalledWith(2, "uname -m", [], { expect(execMock).toHaveBeenNthCalledWith(2, "uname -m", [], {
listeners: { stdout: expect.any(Function) }, listeners: { stdout: expect.any(Function) },
}); });
expect(downloadToolMock).toHaveBeenCalledWith( expect(downloadToolMock).toHaveBeenCalledWith(
"https://github.com/docker/compose/releases/download/v2.29.0/docker-compose--", "https://github.com/docker/compose/releases/download/v2.29.0/docker-compose-Linux-x86_64",
"/home/test/.docker/cli-plugins/docker-compose" "/home/test/.docker/cli-plugins/docker-compose",
); );
expect(cacheFileMock).toHaveBeenCalledWith( expect(cacheFileMock).toHaveBeenCalledWith(
"/home/test/.docker/cli-plugins/docker-compose", "/home/test/.docker/cli-plugins/docker-compose",
"docker-compose", "docker-compose",
"docker-compose", "docker-compose",
version version,
); );
}); });
it("should use DOCKER_CONFIG when set", async () => { it("should use DOCKER_CONFIG when set", async () => {
// Arrange // Arrange
const version = "v2.29.0"; const version = "v2.29.0";
execMock.mockImplementationOnce(async (_command, _args, options) => { execMock.mockImplementationOnce(async (_command, _args, options) => {
options?.listeners?.stdout?.(Buffer.from("Linux\n")); options?.listeners?.stdout?.(Buffer.from("Linux\n"));
return 0; return 0;
}); });
execMock.mockImplementationOnce(async (_command, _args, options) => { execMock.mockImplementationOnce(async (_command, _args, options) => {
options?.listeners?.stdout?.(Buffer.from("x86_64\n")); options?.listeners?.stdout?.(Buffer.from("x86_64\n"));
return 0; return 0;
}); });
process.env.DOCKER_CONFIG = "/custom/docker"; process.env.DOCKER_CONFIG = "/custom/docker";
// Act // Act
await adapter.install(version); await adapter.install(version);
// Assert // Assert
expect(downloadToolMock).toHaveBeenCalledWith( expect(downloadToolMock).toHaveBeenCalledWith(
"https://github.com/docker/compose/releases/download/v2.29.0/docker-compose-Linux-x86_64", "https://github.com/docker/compose/releases/download/v2.29.0/docker-compose-Linux-x86_64",
"/custom/docker/cli-plugins/docker-compose" "/custom/docker/cli-plugins/docker-compose",
); );
}); });
it("should handle version without 'v' prefix", async () => { it("should handle version without 'v' prefix", async () => {
// Arrange // Arrange
const version = "2.29.0"; const version = "2.29.0";
// Uname -s execMock.mockImplementationOnce(async (_command, _args, options) => {
execMock.mockResolvedValueOnce(0); options?.listeners?.stdout?.(Buffer.from("Linux\n"));
return 0;
});
// Uname -m execMock.mockImplementationOnce(async (_command, _args, options) => {
execMock.mockResolvedValueOnce(0); options?.listeners?.stdout?.(Buffer.from("x86_64\n"));
return 0;
});
Object.defineProperty(process.env, "HOME", { process.env.HOME = "/home/test";
value: "/home/test",
});
// Act // Act
await adapter.install(version); await adapter.install(version);
// Assert // Assert
expect(mkdirPMock).toHaveBeenCalledWith("docker-compose"); expect(mkdirPMock).toHaveBeenCalledWith("docker-compose");
expect(execMock).toHaveBeenNthCalledWith(1, "uname -s", [], { expect(execMock).toHaveBeenNthCalledWith(1, "uname -s", [], {
listeners: { stdout: expect.any(Function) }, listeners: { stdout: expect.any(Function) },
}); });
expect(execMock).toHaveBeenNthCalledWith(2, "uname -m", [], { expect(execMock).toHaveBeenNthCalledWith(2, "uname -m", [], {
listeners: { stdout: expect.any(Function) }, listeners: { stdout: expect.any(Function) },
}); });
expect(downloadToolMock).toHaveBeenCalledWith( expect(downloadToolMock).toHaveBeenCalledWith(
"https://github.com/docker/compose/releases/download/v2.29.0/docker-compose--", "https://github.com/docker/compose/releases/download/v2.29.0/docker-compose-Linux-x86_64",
"/home/test/.docker/cli-plugins/docker-compose" "/home/test/.docker/cli-plugins/docker-compose",
); );
}); });
it("should not add 'v' prefix for 1.x versions", async () => { it("should not add 'v' prefix for 1.x versions", async () => {
// Arrange // Arrange
const version = "1.29.0"; const version = "1.29.0";
execMock.mockImplementationOnce(async (_command, _args, options) => { execMock.mockImplementationOnce(async (_command, _args, options) => {
options?.listeners?.stdout?.(Buffer.from("Linux\n")); options?.listeners?.stdout?.(Buffer.from("Linux\n"));
return 0; return 0;
}); });
execMock.mockImplementationOnce(async (_command, _args, options) => { execMock.mockImplementationOnce(async (_command, _args, options) => {
options?.listeners?.stdout?.(Buffer.from("x86_64\n")); options?.listeners?.stdout?.(Buffer.from("x86_64\n"));
return 0; return 0;
}); });
delete process.env.DOCKER_CONFIG; delete process.env.DOCKER_CONFIG;
Object.defineProperty(process.env, "HOME", { process.env.HOME = "/home/test";
value: "/home/test",
});
// Act // Act
await adapter.install(version); await adapter.install(version);
// Assert // Assert
expect(downloadToolMock).toHaveBeenCalledWith( expect(downloadToolMock).toHaveBeenCalledWith(
"https://github.com/docker/compose/releases/download/1.29.0/docker-compose-Linux-x86_64", "https://github.com/docker/compose/releases/download/1.29.0/docker-compose-Linux-x86_64",
"/home/test/.docker/cli-plugins/docker-compose" "/home/test/.docker/cli-plugins/docker-compose",
); );
}); });
it("should throw an error if a command fails", async () => { it("should throw an error if a command fails", async () => {
// Arrange // Arrange
const version = "v2.29.0"; const version = "v2.29.0";
// Uname -s // Uname -s
execMock.mockResolvedValueOnce(1); execMock.mockResolvedValueOnce(1);
// Act // Act
await expect(adapter.install(version)).rejects.toThrow("Failed to run command: uname -s"); await expect(adapter.install(version)).rejects.toThrow(
"Failed to run command: uname -s",
);
// Assert // Assert
expect(execMock).toHaveBeenNthCalledWith(1, "uname -s", [], { expect(execMock).toHaveBeenNthCalledWith(1, "uname -s", [], {
listeners: { stdout: expect.any(Function) }, listeners: { stdout: expect.any(Function) },
}); });
}); });
}); });
}); });

View File

@ -2,59 +2,68 @@ import { exec } from "@actions/exec";
import { mkdirP } from "@actions/io"; import { mkdirP } from "@actions/io";
import { basename } from "node:path"; import { basename } from "node:path";
import { cacheFile, downloadTool } from "@actions/tool-cache"; import { cacheFile, downloadTool } from "@actions/tool-cache";
import { DockerComposeInstallerAdapter } from "./docker-compose-installer-adapter.js"; import type { DockerComposeInstallerAdapter } from "./docker-compose-installer-adapter.js";
export class ManualInstallerAdapter implements DockerComposeInstallerAdapter { export class ManualInstallerAdapter implements DockerComposeInstallerAdapter {
async install(version: string): Promise<void> { async install(version: string): Promise<void> {
const dockerComposePluginPath = await this.getDockerComposePluginPath(); const dockerComposePluginPath = await this.getDockerComposePluginPath();
// Create the directory if it doesn't exist // Create the directory if it doesn't exist
await mkdirP(basename(dockerComposePluginPath)); await mkdirP(basename(dockerComposePluginPath));
await this.downloadFile(version, dockerComposePluginPath); await this.downloadFile(version, dockerComposePluginPath);
await exec(`chmod +x ${dockerComposePluginPath}`); await exec(`chmod +x ${dockerComposePluginPath}`);
await cacheFile(dockerComposePluginPath, "docker-compose", "docker-compose", version); await cacheFile(
} dockerComposePluginPath,
"docker-compose",
"docker-compose",
version,
);
}
private async getDockerComposePluginPath(): Promise<string> { private async getDockerComposePluginPath(): Promise<string> {
const dockerConfig = process.env.DOCKER_CONFIG || `${process.env.HOME}/.docker`; const dockerConfig =
process.env.DOCKER_CONFIG || `${process.env.HOME}/.docker`;
const dockerComposePluginPath = `${dockerConfig}/cli-plugins/docker-compose`; const dockerComposePluginPath = `${dockerConfig}/cli-plugins/docker-compose`;
return dockerComposePluginPath; return dockerComposePluginPath;
} }
private async downloadFile(version: string, installerPath: string): Promise<void> { private async downloadFile(
if (!version.startsWith("v") && parseInt(version.split(".")[0], 10) >= 2) { version: string,
version = `v${version}`; installerPath: string,
} ): Promise<void> {
if (!version.startsWith("v") && parseInt(version.split(".")[0], 10) >= 2) {
version = `v${version}`;
}
const system = await this.getSystem(); const system = await this.getSystem();
const hardware = await this.getHardware(); const hardware = await this.getHardware();
const url = `https://github.com/docker/compose/releases/download/${version}/docker-compose-${system}-${hardware}`; const url = `https://github.com/docker/compose/releases/download/${version}/docker-compose-${system}-${hardware}`;
await downloadTool(url, installerPath); await downloadTool(url, installerPath);
} }
private async getSystem(): Promise<string> { private async getSystem(): Promise<string> {
return this.runCommand("uname -s"); return this.runCommand("uname -s");
} }
private async getHardware(): Promise<string> { private async getHardware(): Promise<string> {
return this.runCommand("uname -m"); return this.runCommand("uname -m");
} }
private async runCommand(command: string): Promise<string> { private async runCommand(command: string): Promise<string> {
let output = ""; let output = "";
const result = await exec(command, [], { const result = await exec(command, [], {
listeners: { listeners: {
stdout: (data: Buffer) => { stdout: (data: Buffer) => {
output += data.toString(); output += data.toString();
}, },
}, },
}); });
if (result !== 0) { if (result !== 0) {
throw new Error(`Failed to run command: ${command}`); throw new Error(`Failed to run command: ${command}`);
} }
return output.trim(); return output.trim();
} }
} }

View File

@ -1,68 +1,68 @@
import { jest, describe, it, expect, beforeEach } from "@jest/globals"; import { describe, expect, it, beforeEach, vi } from "vitest";
// Import types directly from the module // Import types directly from the module
import type { LogLevel as LogLevelType } from "./logger.service.js"; import type { LogLevel as LogLevelType } from "./logger.service.js";
// Mock @actions/core before importing the module under test // Mock @actions/core before importing the module under test
const warningMock = jest.fn(); const warningMock = vi.fn();
const infoMock = jest.fn(); const infoMock = vi.fn();
const debugMock = jest.fn(); const debugMock = vi.fn();
jest.unstable_mockModule("@actions/core", () => ({ vi.doMock("@actions/core", () => ({
warning: warningMock, warning: warningMock,
info: infoMock, info: infoMock,
debug: debugMock, debug: debugMock,
})); }));
// Dynamic import after mock setup // Dynamic import after mock setup
const { LoggerService, LogLevel } = await import("./logger.service.js"); const { LoggerService, LogLevel } = await import("./logger.service.js");
describe("LoggerService", () => { describe("LoggerService", () => {
let loggerService: InstanceType<typeof LoggerService>; let loggerService: InstanceType<typeof LoggerService>;
beforeEach(() => { beforeEach(() => {
jest.clearAllMocks(); vi.clearAllMocks();
loggerService = new LoggerService(); loggerService = new LoggerService();
}); });
describe("warn", () => { describe("warn", () => {
it("should call warning with the correct message", () => { it("should call warning with the correct message", () => {
const message = "This is a warning message"; const message = "This is a warning message";
loggerService.warn(message); loggerService.warn(message);
expect(warningMock).toHaveBeenCalledWith(message); expect(warningMock).toHaveBeenCalledWith(message);
}); });
}); });
describe("info", () => { describe("info", () => {
it("should call info with the correct message", () => { it("should call info with the correct message", () => {
const message = "This is an info message"; const message = "This is an info message";
loggerService.info(message); loggerService.info(message);
expect(infoMock).toHaveBeenCalledWith(message); expect(infoMock).toHaveBeenCalledWith(message);
}); });
}); });
describe("debug", () => { describe("debug", () => {
it("should call debug with the correct message", () => { it("should call debug with the correct message", () => {
const message = "This is a debug message"; const message = "This is a debug message";
loggerService.debug(message); loggerService.debug(message);
expect(debugMock).toHaveBeenCalledWith(message); expect(debugMock).toHaveBeenCalledWith(message);
}); });
}); });
describe("getServiceLogger", () => { describe("getServiceLogger", () => {
it("should return the correct logger function for debug level", () => { it("should return the correct logger function for debug level", () => {
const logger = loggerService.getServiceLogger(LogLevel.Debug); const logger = loggerService.getServiceLogger(LogLevel.Debug);
expect(logger).toBe(loggerService.debug); expect(logger).toBe(loggerService.debug);
}); });
it("should return the correct logger function for info level", () => { it("should return the correct logger function for info level", () => {
const logger = loggerService.getServiceLogger(LogLevel.Info); const logger = loggerService.getServiceLogger(LogLevel.Info);
expect(logger).toBe(loggerService.info); expect(logger).toBe(loggerService.info);
}); });
it("should default to info level if an unknown level is provided", () => { it("should default to info level if an unknown level is provided", () => {
const logger = loggerService.getServiceLogger("unknown" as LogLevelType); const logger = loggerService.getServiceLogger("unknown" as LogLevelType);
expect(logger).toBe(loggerService.info); expect(logger).toBe(loggerService.info);
}); });
}); });
}); });

View File

@ -1,31 +1,31 @@
import { debug, info, warning } from "@actions/core"; import { debug, info, warning } from "@actions/core";
export class LoggerService { export class LoggerService {
warn(message: string): void { warn(message: string): void {
warning(message); warning(message);
} }
info(message: string): void { info(message: string): void {
info(message); info(message);
} }
debug(message: string) { debug(message: string) {
debug(message); debug(message);
} }
getServiceLogger(level: LogLevel): (message: string) => void { getServiceLogger(level: LogLevel): (message: string) => void {
switch (level) { switch (level) {
case LogLevel.Debug: case LogLevel.Debug:
return this.debug; return this.debug;
case LogLevel.Info: case LogLevel.Info:
return this.info; return this.info;
default: default:
return this.info; return this.info;
} }
} }
} }
export enum LogLevel { export enum LogLevel {
Debug = "debug", Debug = "debug",
Info = "info", Info = "info",
} }

View File

@ -6,6 +6,8 @@ WORKDIR /app
COPY entrypoint.sh . COPY entrypoint.sh .
RUN chmod +x entrypoint.sh RUN chmod +x entrypoint.sh
HEALTHCHECK CMD grep -qa "entrypoint.sh" /proc/1/cmdline || exit 1
CMD ["/bin/sh", "entrypoint.sh"] CMD ["/bin/sh", "entrypoint.sh"]
USER 1000:1000 USER 1000:1000

View File

@ -1,21 +1,21 @@
{ {
"$schema": "https://json.schemastore.org/tsconfig", "$schema": "https://json.schemastore.org/tsconfig",
"compilerOptions": { "compilerOptions": {
"target": "ES2022", "target": "ES2022",
"module": "NodeNext", "module": "NodeNext",
"rootDir": "./src", "rootDir": "./src",
"moduleResolution": "NodeNext", "moduleResolution": "NodeNext",
"baseUrl": "./", "baseUrl": "./",
"sourceMap": true, "sourceMap": true,
"outDir": "./dist", "outDir": "./dist",
"noImplicitAny": true, "noImplicitAny": true,
"esModuleInterop": true, "esModuleInterop": true,
"forceConsistentCasingInFileNames": true, "forceConsistentCasingInFileNames": true,
"strict": true, "strict": true,
"skipLibCheck": true, "skipLibCheck": true,
"newLine": "lf", "newLine": "lf",
"noEmit": true "noEmit": true
}, },
"exclude": ["./dist", "./node_modules", "./src/**/*.test.ts", "./coverage"], "exclude": ["./dist", "./node_modules", "./src/**/*.test.ts", "./coverage"],
"include": ["./src/**/*"] "include": ["./src/**/*"]
} }

12
vitest.config.ts Normal file
View File

@ -0,0 +1,12 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
coverage: {
include: ["src/**/*.{ts,tsx,js,jsx}"],
provider: "v8",
},
environment: "node",
globals: true,
},
});