Skip to content

Commit 184603c

Browse files
committed
run ui e2e tests in github actions
1 parent 41018d0 commit 184603c

File tree

8 files changed

+211
-34
lines changed

8 files changed

+211
-34
lines changed

.github/workflows/ui.yaml

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
name: UI tests
2+
3+
on:
4+
pull_request:
5+
push:
6+
branches:
7+
- master
8+
9+
jobs:
10+
ui-e2e-tests:
11+
runs-on: ubuntu-latest
12+
steps:
13+
- uses: actions/checkout@v5
14+
with:
15+
fetch-depth: 0
16+
17+
- name: Free disk space (delete unused tools)
18+
id: delete-unused-tools
19+
continue-on-error: true
20+
shell: bash
21+
run: |
22+
free_disk_space=22
23+
# delete preinstalled unused tools
24+
cleanup=(
25+
/usr/share/dotnet
26+
/usr/share/miniconda
27+
/usr/share/swift
28+
/usr/share/kotlinc
29+
/opt/ghc
30+
/opt/hostedtoolcache/CodeQL
31+
/opt/hostedtoolcache/Ruby
32+
/opt/az
33+
/usr/local/lib/android
34+
)
35+
for d in "${cleanup[@]}"; do
36+
if [[ -d "$d" ]]; then
37+
rm -rf -- "$d" && echo "deleted $d"
38+
else
39+
echo "$d not found"
40+
continue
41+
fi
42+
free=$(df -BGB --output=avail / | tail -1)
43+
if [[ ${free%GB} -ge "${free_disk_space}" ]]; then
44+
echo "Reached requested free disk space ${free_disk_space} [${free} free]."
45+
exit 0
46+
fi
47+
done
48+
df -h
49+
50+
- name: Set up Docker Buildx
51+
uses: docker/setup-buildx-action@v3
52+
53+
- name: Create KinD Cluster
54+
uses: helm/kind-action@v1
55+
with:
56+
cluster_name: kind
57+
58+
- name: tags
59+
run: |
60+
echo "TAG=$(make tag)" | tee -a "$GITHUB_ENV"
61+
62+
- name: Build Docker image
63+
uses: docker/build-push-action@v5
64+
with:
65+
file: image/Dockerfile
66+
context: .
67+
push: false
68+
load: true
69+
tags: quay.io/rhacs-eng/infra-server:${{ env.TAG }}
70+
cache-from: type=gha
71+
cache-to: type=gha,mode=max
72+
73+
- name: Load into KinD
74+
run: |
75+
# Check cluster name
76+
kind get clusters
77+
#docker build -t quay.io/rhacs-eng/infra-server:${{ env.TAG }} -f image/Dockerfile .
78+
kind load docker-image quay.io/rhacs-eng/infra-server:${{ env.TAG }} --name kind
79+
docker images | grep infra-server
80+
81+
- name: Deploy
82+
run: make deploy-local
83+
84+
- name: Wait for pods
85+
run: kubectl wait --for=condition=ready pod -l app=infra-server -n infra --timeout=3m
86+
87+
- name: Start port-forward
88+
run: |
89+
kubectl port-forward -n infra svc/infra-server-service 8443:8443 >/dev/null 2>&1 &
90+
echo "PORT_FORWARD_PID=$!" >> "$GITHUB_ENV"
91+
sleep 5
92+
# Verify port-forward is working
93+
timeout 10 sh -c 'until curl -k -f https://localhost:8443/v1/whoami 2>/dev/null; do sleep 1; done' || echo "Warning: Backend may not be ready"
94+
95+
- name: Run E2E tests
96+
uses: cypress-io/github-action@v6
97+
with:
98+
working-directory: ui
99+
start: npm run start
100+
wait-on: 'http://localhost:3001'
101+
wait-on-timeout: 60
102+
command: npm run cypress:run:e2e
103+
env:
104+
BROWSER: none
105+
PORT: 3001
106+
# Backend uses HTTPS with self-signed cert (see scripts/deploy/helm.sh)
107+
INFRA_API_ENDPOINT: https://localhost:8443
108+
109+
- name: Upload test artifacts
110+
if: failure()
111+
uses: actions/upload-artifact@v4
112+
with:
113+
name: cypress-artifacts
114+
path: |
115+
ui/cypress/videos
116+
ui/cypress/screenshots
117+
retention-days: 7
118+
119+
- name: Cleanup port-forward
120+
if: always()
121+
run: |
122+
# Kill by PID if available, otherwise kill by process name
123+
if [ -n "${{ env.PORT_FORWARD_PID }}" ]; then
124+
echo "Cleaning up port-forward (PID: ${{ env.PORT_FORWARD_PID }})..."
125+
kill ${{ env.PORT_FORWARD_PID }} 2>/dev/null || true
126+
fi
127+
# Fallback: kill any remaining port-forward processes
128+
pkill -f "kubectl port-forward.*8443:8443" 2>/dev/null || true

DEPLOYMENT.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -118,7 +118,7 @@ This is used in the infra PR clusters to set the login referer and disable telem
118118

119119
#### Deployments for testing only (no secrets)
120120

121-
For test clusters (such as a local KinD/Colima), you can use the deploy-local make target to skip loading secrets. The flavor provisioning actions that require secrets will no be accessible, and integrations such as with Slack will be disabled.
121+
For test clusters (such as a local KinD/Colima), you can use the deploy-local make target to skip loading secrets. The flavor provisioning actions that require secrets will not be accessible, and integrations such as with Slack will be disabled.
122122

123123
`make deploy-local`
124124

scripts/deploy/helm.sh

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -135,7 +135,10 @@ deploy-local() {
135135
if [[ ! -f "${cert_file}" ]] || [[ ! -f "${key_file}" ]]; then
136136
echo "Generating self-signed certificate for local development..." >&2
137137
# Create a temporary config file for SAN extension
138-
local san_config=$(mktemp)
138+
# SAN (Subject Alternative Name) is required for gRPC-Gateway TLS validation in modern Go versions
139+
# Without SAN, you'll get: "x509: certificate relies on legacy Common Name field"
140+
local san_config
141+
san_config=$(mktemp) || { echo "Failed to create temporary config file" >&2; return 1; }
139142
cat > "${san_config}" <<EOF
140143
[req]
141144
distinguished_name = req_distinguished_name

ui/TESTING.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,15 @@ This directory contains Cypress E2E tests for the StackRox Infra UI.
66

77
### Prerequisites
88

9+
0. **Build** images:
10+
11+
```bash
12+
make image
13+
```
14+
15+
In your clone of the repository, you must build the infra-server into the
16+
image for it to be deployed in the next step.
17+
918
1. **Deploy the local backend** (with authentication disabled):
1019

1120
```bash

ui/cypress.config.ts

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,20 @@
22
import { defineConfig } from 'cypress';
33
import * as crypto from 'crypto';
44

5+
interface JWTPayload {
6+
user: {
7+
Name: string;
8+
Email: string;
9+
Picture: string;
10+
Expiry: {
11+
seconds: number;
12+
};
13+
};
14+
exp: number;
15+
nbf: number;
16+
iat: number;
17+
}
18+
519
export default defineConfig({
620
e2e: {
721
// UI dev server runs on :3001
@@ -21,15 +35,15 @@ export default defineConfig({
2135
setupNodeEvents(on, config) {
2236
// Task to generate JWT tokens for local dev authentication
2337
on('task', {
24-
generateJWT({ payload, secret }: { payload: any; secret: string }) {
38+
generateJWT({ payload, secret }: { payload: JWTPayload; secret: string }): string {
2539
// Create JWT header
2640
const header = {
2741
alg: 'HS256',
2842
typ: 'JWT',
2943
};
3044

3145
// Base64url encode header and payload
32-
const base64UrlEncode = (obj: any) =>
46+
const base64UrlEncode = (obj: object) =>
3347
Buffer.from(JSON.stringify(obj))
3448
.toString('base64')
3549
.replace(/\+/g, '-')
Lines changed: 42 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,16 @@
1+
const ERROR_MESSAGES = {
2+
ACCESS_DENIED: 'access denied',
3+
UNEXPECTED_ERROR: 'There was an unexpected error',
4+
};
5+
6+
const SELECTORS = {
7+
FLAVOR_CARD: '.pf-v6-c-card',
8+
CARD_TITLE: '.pf-v6-c-card__title',
9+
LABEL: '.pf-v6-c-label',
10+
FLAVOR_TOGGLE: 'input[name="flavor-filter-toggle"]',
11+
PAGE_HEADING: 'h2',
12+
};
13+
114
describe('Flavor Selection', () => {
215
beforeEach(() => {
316
// Authenticate for local development before visiting the page
@@ -7,41 +20,41 @@ describe('Flavor Selection', () => {
720

821
it('should load the page without authentication errors', () => {
922
// Verify no error messages (confirms LOCAL_DEPLOY mode is working)
10-
cy.get('body').should('not.contain', 'access denied');
11-
cy.get('body').should('not.contain', 'There was an unexpected error');
23+
cy.get('body').should('not.contain', ERROR_MESSAGES.ACCESS_DENIED);
24+
cy.get('body').should('not.contain', ERROR_MESSAGES.UNEXPECTED_ERROR);
1225
});
1326

1427
it('should display a list of available flavors', () => {
15-
// Wait for flavors to load (check for either "My Flavors" or "All Flavors" title)
16-
cy.contains('h2', /My Flavors|All Flavors/).should('be.visible');
28+
// Wait for the page heading to be visible (indicates page has loaded)
29+
cy.get(SELECTORS.PAGE_HEADING).should('be.visible');
1730

1831
// Verify that the flavor gallery is not empty
1932
// Each flavor is rendered as a LinkCard inside a GalleryItem
20-
cy.get('.pf-v6-c-card').should('have.length.at.least', 1);
33+
cy.get(SELECTORS.FLAVOR_CARD).should('have.length.at.least', 1);
2134
});
2235

2336
it('should display flavor details for each flavor card', () => {
2437
// Wait for flavors to load
25-
cy.contains('h2', /My Flavors|All Flavors/).should('be.visible');
38+
cy.get(SELECTORS.PAGE_HEADING).should('be.visible');
2639

2740
// Get the first flavor card and verify it has required elements
28-
cy.get('.pf-v6-c-card')
41+
cy.get(SELECTORS.FLAVOR_CARD)
2942
.first()
3043
.within(() => {
3144
// Each flavor card should have a name (header text)
32-
cy.get('.pf-v6-c-card__title').should('exist').and('not.be.empty');
45+
cy.get(SELECTORS.CARD_TITLE).should('exist').and('not.be.empty');
3346

3447
// Each flavor card should have an availability label
35-
cy.get('.pf-v6-c-label').should('exist');
48+
cy.get(SELECTORS.LABEL).should('exist');
3649
});
3750
});
3851

3952
it('should have clickable flavor cards that navigate to launch page', () => {
4053
// Wait for flavors to load
41-
cy.contains('h2', /My Flavors|All Flavors/).should('be.visible');
54+
cy.get(SELECTORS.PAGE_HEADING).should('be.visible');
4255

4356
// Click the first flavor card
44-
cy.get('.pf-v6-c-card').first().click();
57+
cy.get(SELECTORS.FLAVOR_CARD).first().click();
4558

4659
// Verify navigation to launch page (URL should contain /launch/)
4760
cy.url().should('include', '/launch/');
@@ -50,24 +63,27 @@ describe('Flavor Selection', () => {
5063
cy.contains('h1', /Launch/).should('be.visible');
5164
});
5265

53-
it('should toggle between "My Flavors" and "All Flavors"', () => {
54-
// Verify initial state
55-
cy.contains('h2', 'My Flavors').should('be.visible');
56-
57-
// Find and click the "Show All Flavors" toggle switch
58-
// Use force:true because PatternFly switch has a visual element covering the input
59-
cy.get('input[name="flavor-filter-toggle"]').click({ force: true });
66+
it('should toggle between flavor filter states', () => {
67+
// Get the initial heading text
68+
cy.get(SELECTORS.PAGE_HEADING)
69+
.should('be.visible')
70+
.invoke('text')
71+
.then((initialHeading) => {
72+
// Find and click the flavor filter toggle switch
73+
// Use force:true because PatternFly switch has a visual element covering the input
74+
cy.get(SELECTORS.FLAVOR_TOGGLE).click({ force: true });
6075

61-
// Verify the title changed to "All Flavors"
62-
cy.contains('h2', 'All Flavors').should('be.visible');
76+
// Verify the heading text changed
77+
cy.get(SELECTORS.PAGE_HEADING).invoke('text').should('not.equal', initialHeading);
6378

64-
// Verify URL parameter was updated
65-
cy.url().should('include', 'showAllFlavors=true');
79+
// Verify URL parameter was updated
80+
cy.url().should('include', 'showAllFlavors=true');
6681

67-
// Toggle back
68-
cy.get('input[name="flavor-filter-toggle"]').click({ force: true });
82+
// Toggle back
83+
cy.get(SELECTORS.FLAVOR_TOGGLE).click({ force: true });
6984

70-
// Verify we're back to "My Flavors"
71-
cy.contains('h2', 'My Flavors').should('be.visible');
85+
// Verify we're back to the original heading
86+
cy.get(SELECTORS.PAGE_HEADING).invoke('text').should('equal', initialHeading);
87+
});
7288
});
7389
});

ui/cypress/e2e/home.cy.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,8 @@
1+
const ERROR_MESSAGES = {
2+
ACCESS_DENIED: 'access denied',
3+
UNEXPECTED_ERROR: 'There was an unexpected error',
4+
};
5+
16
describe('Home Page', () => {
27
beforeEach(() => {
38
// Authenticate for local development before visiting the page
@@ -10,7 +15,7 @@ describe('Home Page', () => {
1015
});
1116

1217
it('should not show error messages', () => {
13-
cy.get('body').should('not.contain', 'access denied');
14-
cy.get('body').should('not.contain', 'There was an unexpected error');
18+
cy.get('body').should('not.contain', ERROR_MESSAGES.ACCESS_DENIED);
19+
cy.get('body').should('not.contain', ERROR_MESSAGES.UNEXPECTED_ERROR);
1520
});
1621
});

ui/cypress/support/commands.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,14 +5,16 @@
55
* This uses the known session secret from local-deploy oidc.yaml.
66
*/
77
Cypress.Commands.add('loginForLocalDev', () => {
8-
// The session secret from chart/infra-server/templates/secrets.yaml for local deploy
8+
// IMPORTANT: This secret is ONLY for local development (LOCAL_DEPLOY=true).
9+
// It matches chart/infra-server/configuration/local-values.yaml
10+
// Production deployments use different secrets from GCP Secret Manager.
911
const sessionSecret = 'local-dev-secret-min-32-chars-long';
1012

1113
// Create a test user matching the backend's expected structure
1214
// Note: Fields are capitalized to match Go's JSON serialization of protobuf structs
1315
const testUser = {
1416
Name: 'Test User',
15-
Email: 'test@redhat.com',
17+
Email: 'test@redhat.com', // Backend requires @redhat.com domain (see pkg/auth/tokenizer.go:128)
1618
Picture: '',
1719
Expiry: {
1820
seconds: Math.floor(Date.now() / 1000) + 3600, // 1 hour from now

0 commit comments

Comments
 (0)