Skip to content

Commit 7acc32d

Browse files
authored
test(act): add provsioned plugins argo workflow trigger tests (#528)
1 parent 9b7a992 commit 7acc32d

7 files changed

Lines changed: 511 additions & 21 deletions

File tree

tests/act/internal/act/act.go

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,10 @@ type Runner struct {
5959
// GCOM is the GCOM API mock used during the workflow run.
6060
GCOM *GCOM
6161

62+
// Argo is the Argo mock used during the workflow run.
63+
// It records inputs from the mocked Argo Workflow trigger step.
64+
Argo *HTTPSpy
65+
6266
// gitHubToken is the token used to authenticate with GitHub.
6367
gitHubToken string
6468

@@ -128,7 +132,10 @@ func NewRunner(t *testing.T, opts ...RunnerOption) (*Runner, error) {
128132
t: t,
129133
uuid: uuid.New(),
130134
gitHubToken: ghToken,
131-
GCOM: newGCOM(t),
135+
GCOM: newGCOM(t),
136+
Argo: NewHTTPSpy(t, map[string]string{
137+
"uri": "https://mock-argo-workflows.example.com/workflows/grafana-plugins-cd/mock-workflow-id",
138+
}),
132139
}
133140
if err := r.checkExecutables(); err != nil {
134141
return nil, err

tests/act/internal/act/httpspy.go

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
package act
2+
3+
import (
4+
"encoding/json"
5+
"fmt"
6+
"net"
7+
"net/http"
8+
"net/http/httptest"
9+
"sync"
10+
"testing"
11+
)
12+
13+
// SpyCallInputs represents the inputs recorded from a single call to the HTTPSpy.
14+
type SpyCallInputs struct {
15+
Inputs map[string]any
16+
}
17+
18+
// HTTPSpy is a generic HTTP server that records incoming requests for testing.
19+
// It can be used to mock any external service that workflow steps call via HTTP,
20+
// recording the inputs for later assertions and returning configurable outputs.
21+
//
22+
// This is useful for mocking actions that run inside act's Docker containers,
23+
// which cannot directly communicate with the Go test process.
24+
//
25+
// HTTPSpy and its methods are safe for concurrent use.
26+
type HTTPSpy struct {
27+
t *testing.T
28+
server *httptest.Server
29+
outputs map[string]string
30+
calls []SpyCallInputs
31+
mux sync.Mutex
32+
}
33+
34+
// NewHTTPSpy creates a new HTTPSpy server that listens on all interfaces.
35+
// The server is automatically cleaned up when the test finishes.
36+
//
37+
// The outputs parameter is a map of output names to values that will be returned
38+
// as JSON for all POST requests. These correspond to action outputs that the
39+
// calling step can parse and set as step outputs.
40+
//
41+
// The server records all incoming POST requests, parsing the JSON body as inputs.
42+
//
43+
// To make the server accessible from Docker containers (e.g., act containers),
44+
// use DockerAccessibleURL() instead of the server's direct URL.
45+
func NewHTTPSpy(t *testing.T, outputs map[string]string) *HTTPSpy {
46+
spy := &HTTPSpy{
47+
t: t,
48+
outputs: outputs,
49+
calls: make([]SpyCallInputs, 0),
50+
}
51+
52+
mux := http.NewServeMux()
53+
mux.HandleFunc("POST /", spy.handleRequest)
54+
55+
// Create a listener on all interfaces (0.0.0.0) so Docker containers can reach it
56+
listener, err := net.Listen("tcp", "0.0.0.0:0")
57+
if err != nil {
58+
t.Fatalf("failed to create listener for HTTPSpy: %v", err)
59+
}
60+
61+
server := &httptest.Server{
62+
Listener: listener,
63+
Config: &http.Server{Handler: mux},
64+
}
65+
server.Start()
66+
67+
spy.server = server
68+
69+
t.Cleanup(func() {
70+
server.Close()
71+
})
72+
73+
return spy
74+
}
75+
76+
// handleRequest handles incoming POST requests, recording the JSON body as inputs
77+
// and returning the configured outputs as JSON.
78+
func (s *HTTPSpy) handleRequest(w http.ResponseWriter, r *http.Request) {
79+
defer func() { _ = r.Body.Close() }()
80+
var inputs map[string]any
81+
if err := json.NewDecoder(r.Body).Decode(&inputs); err != nil {
82+
s.t.Logf("HTTPSpy: failed to parse JSON body: %v", err)
83+
w.WriteHeader(http.StatusBadRequest)
84+
return
85+
}
86+
87+
s.recordCall(SpyCallInputs{Inputs: inputs})
88+
89+
w.Header().Set("Content-Type", "application/json")
90+
w.WriteHeader(http.StatusOK)
91+
_ = json.NewEncoder(w).Encode(s.outputs) //nolint:errcheck
92+
}
93+
94+
// recordCall records a call with the given inputs.
95+
func (s *HTTPSpy) recordCall(inputs SpyCallInputs) {
96+
s.mux.Lock()
97+
defer s.mux.Unlock()
98+
s.calls = append(s.calls, inputs)
99+
}
100+
101+
// GetCalls returns all recorded calls.
102+
// This method is safe for concurrent use.
103+
func (s *HTTPSpy) GetCalls() []SpyCallInputs {
104+
s.mux.Lock()
105+
defer s.mux.Unlock()
106+
// Return a copy to avoid race conditions
107+
result := make([]SpyCallInputs, len(s.calls))
108+
copy(result, s.calls)
109+
return result
110+
}
111+
112+
// Reset clears all recorded calls.
113+
// This method is safe for concurrent use.
114+
func (s *HTTPSpy) Reset() {
115+
s.mux.Lock()
116+
defer s.mux.Unlock()
117+
s.calls = make([]SpyCallInputs, 0)
118+
}
119+
120+
// DockerAccessibleURL returns a URL that can be used from inside Docker containers.
121+
// It uses host.docker.internal which works on:
122+
// - Docker Desktop (Mac/Windows): built-in support
123+
// - Linux: enabled via --add-host=host.docker.internal:host-gateway in act.go
124+
func (s *HTTPSpy) DockerAccessibleURL() string {
125+
addr := s.server.Listener.Addr().(*net.TCPAddr)
126+
return fmt.Sprintf("http://host.docker.internal:%d", addr.Port)
127+
}

tests/act/internal/workflow/cd/cd.go

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,8 @@ type WorkflowInputs struct {
121121
DisableDocsPublishing *bool
122122
DisableGitHubRelease *bool
123123
TriggerArgo *bool
124+
AutoMergeEnvironments *string
125+
ArgoWorkflowSlackChannel *string
124126

125127
// GCOMApiURL overrides the GCOM API URL for testing with mock servers.
126128
// Use GCOMMock.DockerAccessibleURL() to get a Docker-accessible URL.
@@ -143,6 +145,8 @@ func WithWorkflowInputs(inputs WorkflowInputs) WorkflowOption {
143145
workflow.SetJobInput(job, "disable-docs-publishing", inputs.DisableDocsPublishing)
144146
workflow.SetJobInput(job, "disable-github-release", inputs.DisableGitHubRelease)
145147
workflow.SetJobInput(job, "trigger-argo", inputs.TriggerArgo)
148+
workflow.SetJobInput(job, "auto-merge-environments", inputs.AutoMergeEnvironments)
149+
workflow.SetJobInput(job, "argo-workflow-slack-channel", inputs.ArgoWorkflowSlackChannel)
146150
workflow.SetJobInput(job, "release-reference-regex", inputs.ReleaseReferenceRegex)
147151
workflow.SetJobInput(job, "docs-only", inputs.DocsOnly)
148152
workflow.SetJobInput(job, "allow-publishing-prs-to-prod", inputs.AllowPublishingPRsToProd)
@@ -162,12 +166,16 @@ func WithCIOptions(opts ...ci.WorkflowOption) WorkflowOption {
162166

163167
// WithMockedArgoWorkflows modifies the SimpleCD workflow to mock the Argo Workflow trigger step
164168
// (which uses the grafana/shared-workflows/actions/trigger-argo-workflow action)
165-
// to instead return a mock URI.
166-
// This allows testing CD workflows without actually triggering Argo Workflows.
167-
func WithMockedArgoWorkflows(t *testing.T) WorkflowOption {
169+
// to instead POST its inputs to the provided HTTPSpy and return a mock URI.
170+
// This allows testing CD workflows without actually triggering Argo Workflows,
171+
// while recording the inputs for later assertions.
172+
//
173+
// Use runner.Argo to get the HTTPSpy instance.
174+
func WithMockedArgoWorkflows(t *testing.T, spy *act.HTTPSpy) WorkflowOption {
168175
return func(w *Workflow) {
176+
url := spy.DockerAccessibleURL()
169177
err := w.CDWorkflow().MockAllStepsUsingAction(workflow.ArgoWorkflowAction, func(step workflow.Step) (workflow.Step, error) {
170-
return workflow.MockArgoWorkflowStep(step)
178+
return workflow.MockArgoWorkflowStep(step, url)
171179
})
172180
require.NoError(t, err, "mock argo workflow step")
173181
}

tests/act/internal/workflow/mock.go

Lines changed: 64 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,55 @@ const (
1818
GitHubAppTokenAction = "actions/create-github-app-token"
1919
)
2020

21+
// MockStepWithHTTPSpy creates a mocked step that POSTs the original step's inputs to an HTTPSpy server
22+
// and sets the response as step outputs.
23+
//
24+
// This is a generic function for mocking any action that uses HTTPSpy for recording inputs.
25+
// The inputs from originalStep.With are passed as environment variables so that GitHub Actions
26+
// expressions (e.g., ${{ needs.ci.outputs.something }}) are evaluated at runtime before being sent.
27+
//
28+
// The mock server should return a JSON object where each key-value pair becomes a step output.
29+
//
30+
// Parameters:
31+
// - originalStep: The original step being mocked (used to extract inputs from With)
32+
// - mockServerURL: The URL of the HTTPSpy server (use HTTPSpy.DockerAccessibleURL())
33+
func MockStepWithHTTPSpy(originalStep Step, mockServerURL string) (Step, error) {
34+
// Pass each input as an environment variable so GitHub Actions expressions are evaluated at runtime.
35+
// We use a prefix to namespace them and collect the keys to build JSON in bash.
36+
const envPrefix = "HTTPSPY_INPUT_"
37+
env := map[string]string{
38+
"HTTPSPY_MOCK_SERVER_URL": mockServerURL,
39+
}
40+
var inputKeys []string
41+
for key, value := range originalStep.With {
42+
env[envPrefix+key] = fmt.Sprintf("%v", value)
43+
inputKeys = append(inputKeys, key)
44+
}
45+
46+
// Serialize input keys to JSON array so bash knows which env vars to read
47+
inputKeysJSON, err := json.Marshal(inputKeys)
48+
if err != nil {
49+
return Step{}, fmt.Errorf("marshal input keys to json: %w", err)
50+
}
51+
env["HTTPSPY_INPUT_KEYS"] = string(inputKeysJSON)
52+
53+
// Build JSON from environment variables at runtime, then POST to mock server
54+
return Step{
55+
Run: Commands{
56+
`echo "Mocking step with HTTPSpy"`,
57+
// Build JSON object from env vars using jq
58+
// For each key in HTTPSPY_INPUT_KEYS, read the corresponding HTTPSPY_INPUT_* env var
59+
`INPUTS_JSON=$(echo "${HTTPSPY_INPUT_KEYS}" | jq -c 'reduce .[] as $key ({}; . + {($key): env["HTTPSPY_INPUT_" + $key]})')`,
60+
// POST the inputs to the mock server and capture the JSON response
61+
`MOCK_RESPONSE=$(curl -s -X POST -H "Content-Type: application/json" -d "${INPUTS_JSON}" "${HTTPSPY_MOCK_SERVER_URL}")`,
62+
// Parse the JSON response and set each key as an output
63+
`echo "${MOCK_RESPONSE}" | jq -r 'to_entries[] | "\(.key)=\(.value)"' >> "$GITHUB_OUTPUT"`,
64+
}.String(),
65+
Shell: "bash",
66+
Env: env,
67+
}, nil
68+
}
69+
2170
// MockOutputsStep returns a Step that only sets the given outputs and does nothing else.
2271
// This can be used to mock the outputs of a step for testing purposes, without executing its real implementation.
2372
func MockOutputsStep(outputs map[string]string) Step {
@@ -27,6 +76,11 @@ func MockOutputsStep(outputs map[string]string) Step {
2776
stepCommands = append(stepCommands, fmt.Sprintf(`echo "%s=${%s}" >> "$GITHUB_OUTPUT"`, k, k))
2877
env[k] = v
2978
}
79+
// If we have no outputs, we must have something in "runs" otherwise the empty string
80+
// will break the yaml file (missing "run" key).
81+
if len(outputs) == 0 {
82+
stepCommands = append(stepCommands, `echo "no outputs to set"`)
83+
}
3084
return Step{
3185
Run: stepCommands.String(),
3286
Env: env,
@@ -308,25 +362,23 @@ func MockVaultSecretsStep(originalStep Step, secrets VaultSecrets) (Step, error)
308362
}
309363

310364
// MockArgoWorkflowStep returns a Step that mocks the grafana/shared-workflows/actions/trigger-argo-workflow action.
311-
// Instead of actually triggering an Argo Workflow, it outputs a mock URI for the workflow.
365+
// Instead of actually triggering an Argo Workflow, it POSTs the original step's inputs to the mock server
366+
// and outputs the response (typically containing the workflow URI).
367+
//
312368
// The originalStep parameter is the original Argo Workflow trigger step to be mocked.
313369
// The original step must use the `grafana/shared-workflows/actions/trigger-argo-workflow` action.
314370
// If those conditions are not met, an error is returned.
315371
//
316-
// The mocked step outputs the `uri` output expected by subsequent steps.
317-
func MockArgoWorkflowStep(originalStep Step) (Step, error) {
318-
// Make sure the original step is indeed an Argo Workflow trigger step
372+
// The mockServerURL parameter is the URL of the HTTPSpy server that will receive the inputs.
373+
// Use HTTPSpy.DockerAccessibleURL() to get a URL that works from inside act's Docker containers.
374+
//
375+
// The mocked step parses the JSON response from the mock server and sets each key as an output.
376+
// The inputs are evaluated at runtime (GitHub Actions expressions are resolved) before being sent.
377+
func MockArgoWorkflowStep(originalStep Step, mockServerURL string) (Step, error) {
319378
if !strings.HasPrefix(originalStep.Uses, ArgoWorkflowAction) {
320379
return Step{}, fmt.Errorf("cannot mock argo workflow for a step that uses %q action, must be %q", originalStep.Uses, ArgoWorkflowAction)
321380
}
322-
323-
return Step{
324-
Run: Commands{
325-
`echo "Mocking Argo Workflow trigger step"`,
326-
`echo "uri=https://mock-argo-workflows.example.com/workflows/grafana-plugins-cd/mock-workflow-id" >> "$GITHUB_OUTPUT"`,
327-
}.String(),
328-
Shell: "bash",
329-
}, nil
381+
return MockStepWithHTTPSpy(originalStep, mockServerURL)
330382
}
331383

332384
// MockGitHubAppTokenStep returns a Step that mocks the actions/create-github-app-token action.

tests/act/internal/workflow/mock_test.go

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -205,9 +205,34 @@ func TestMockArgoWorkflowStep(t *testing.T) {
205205
step := Step{
206206
Name: "Trigger Argo Workflow",
207207
Uses: "grafana/shared-workflows/actions/trigger-argo-workflow@e100806688f1209051080dfea5719fbbd1d18cc0",
208+
With: map[string]any{
209+
"namespace": "grafana-plugins-cd",
210+
"workflow_template": "grafana-plugins-deploy",
211+
"parameters": "slug=test-plugin\nversion=1.0.0",
212+
},
208213
}
209-
mockedStep, err := MockArgoWorkflowStep(step)
214+
mockServerURL := "http://host.docker.internal:12345"
215+
mockedStep, err := MockArgoWorkflowStep(step, mockServerURL)
210216
require.NoError(t, err)
211-
require.Equal(t, "Trigger Argo Workflow (mocked)", mockedStep.Name)
212-
require.Contains(t, mockedStep.Run, `echo "uri=https://mock-argo-workflows.example.com/workflows/grafana-plugins-cd/mock-workflow-id" >> "$GITHUB_OUTPUT"`)
217+
// Note: The mocked name is set by MockAllStepsUsingAction, not by MockArgoWorkflowStep directly
218+
219+
// Verify the step builds JSON from env vars and POSTs to the mock server
220+
require.Contains(t, mockedStep.Run, `INPUTS_JSON=$(echo "${HTTPSPY_INPUT_KEYS}"`)
221+
require.Contains(t, mockedStep.Run, `curl -s -X POST`)
222+
// Verify the step parses JSON response and sets outputs using jq
223+
require.Contains(t, mockedStep.Run, `jq -r 'to_entries[]`)
224+
require.Contains(t, mockedStep.Run, `>> "$GITHUB_OUTPUT"`)
225+
226+
// Verify each input is passed as a separate env var (values will be evaluated at runtime)
227+
// Keys are preserved as-is (no uppercase/underscore transformation)
228+
require.Equal(t, "grafana-plugins-cd", mockedStep.Env["HTTPSPY_INPUT_namespace"])
229+
require.Equal(t, "grafana-plugins-deploy", mockedStep.Env["HTTPSPY_INPUT_workflow_template"])
230+
require.Equal(t, "slug=test-plugin\nversion=1.0.0", mockedStep.Env["HTTPSPY_INPUT_parameters"])
231+
232+
// Verify input keys are passed so bash can build JSON
233+
require.Contains(t, mockedStep.Env["HTTPSPY_INPUT_KEYS"], `"namespace"`)
234+
require.Contains(t, mockedStep.Env["HTTPSPY_INPUT_KEYS"], `"workflow_template"`)
235+
require.Contains(t, mockedStep.Env["HTTPSPY_INPUT_KEYS"], `"parameters"`)
236+
237+
require.Equal(t, mockServerURL, mockedStep.Env["HTTPSPY_MOCK_SERVER_URL"])
213238
}

tests/act/internal/workflow/testing.go

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -172,7 +172,7 @@ func WithOnlyOneJob(t *testing.T, jobID string, removeDependencies bool) Testing
172172
// Remove all jobs
173173
for k := range twf.BaseWorkflow.Jobs {
174174
// Do not remove the given job if it's a dependency and we don't want to remove dependencies
175-
if k == jobID || (slices.Contains(onlyJob.Needs, k) && !removeDependencies) {
175+
if k == jobID || (!removeDependencies && isDependency(twf, onlyJob, k)) {
176176
continue
177177
}
178178
delete(twf.BaseWorkflow.Jobs, k)
@@ -185,13 +185,57 @@ func WithOnlyOneJob(t *testing.T, jobID string, removeDependencies bool) Testing
185185
}
186186
}
187187

188+
// isDependency checks if the given jobID is a dependency (direct or indirect) of the given job.
189+
func isDependency(wf *TestingWorkflow, job *Job, jobID string) bool {
190+
if slices.Contains(job.Needs, jobID) {
191+
// Direct dependency
192+
return true
193+
}
194+
// Recursively check for indirect dependencies
195+
for _, need := range job.Needs {
196+
if isDependency(wf, wf.Jobs()[need], jobID) {
197+
return true
198+
}
199+
}
200+
return false
201+
}
202+
188203
// WithoutJob removes the given job from the workflow.
189204
func WithoutJob(jobID string) TestingWorkflowOption {
190205
return func(twf *TestingWorkflow) {
191206
delete(twf.BaseWorkflow.Jobs, jobID)
192207
}
193208
}
194209

210+
// WithNoOpJobWithOutputs modifies the TestingWorkflow to replace the given job with a no-op job that sets the given outputs.
211+
// This can be used to skip jobs that are not relevant for the test or that would fail otherwise.
212+
// This is useful combined with WithOnlyOneJob to remove all jobs except the given one.
213+
// Then, WithNoOpJobWithOutputs can be used on the remaining job's dependencies to set mock outputs rather than running the actual job.
214+
// This way the remaining job can run (needs.X.outputs.Y will be available) and the test can run.
215+
func WithNoOpJobWithOutputs(t *testing.T, jobID string, outputs map[string]string) TestingWorkflowOption {
216+
return func(twf *TestingWorkflow) {
217+
job, ok := twf.BaseWorkflow.Jobs[jobID]
218+
require.True(t, ok, fmt.Errorf("job %q not found", jobID))
219+
220+
// Reset uses and nil in case the job uses an action rather than steps
221+
job.Uses = ""
222+
job.With = nil
223+
224+
// Enforce runs-on/empty strategy otherwise the job can't run the steps
225+
job.RunsOn = "ubuntu-arm64-small"
226+
job.Strategy = Strategy{}
227+
228+
// Set the steps to a no-op step that sets the outputs
229+
mockStep := MockOutputsStep(outputs)
230+
mockStep.ID = "set-outputs"
231+
job.Outputs = make(map[string]string, len(outputs))
232+
for k := range outputs {
233+
job.Outputs[k] = fmt.Sprintf("${{ steps.%s.outputs.%s }}", mockStep.ID, k)
234+
}
235+
job.Steps = Steps{mockStep}
236+
}
237+
}
238+
195239
// WithReplacedStep replaces the step with the given ID in the given job with the given step.
196240
// This can be used to replace a step with a mocked step for testing purposes.
197241
func WithReplacedStep(t *testing.T, jobID string, stepID string, step Step) TestingWorkflowOption {

0 commit comments

Comments
 (0)