Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 10 additions & 11 deletions .github/workflows/docker.yml
Original file line number Diff line number Diff line change
@@ -1,18 +1,19 @@
name: Docker

on:
push:
branches: [main]
tags:
- v[0-9]+.[0-9]+.[0-9]+*
pull_request:
branches: [main]
workflow_call:
outputs:
image:
description: Published image reference pinned to its digest
value: ${{ jobs.build.outputs.image }}

jobs:
build:
name: Build image

runs-on: ubuntu-latest
outputs:
image: ghcr.io/${{ github.repository_owner }}/chalmers.it@${{ steps.build.outputs.digest }}

steps:
- uses: actions/checkout@v7.0.1
Expand All @@ -33,14 +34,12 @@ jobs:
with:
images: ghcr.io/${{ github.repository_owner }}/chalmers.it
tags: |
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{major}}
type=sha
type=edge
type=ref,event=pr
type=ref,event=pr

- name: Build Docker image (and push on main)
- name: Build and publish Docker image
id: build
uses: docker/build-push-action@v7.3.0
with:
push: true
Expand Down
74 changes: 74 additions & 0 deletions .github/workflows/e2e.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
name: End-to-end tests

on:
pull_request:
branches: [main]
push:
branches: [main]

permissions:
contents: read

jobs:
image:
name: Publish website image
uses: ./.github/workflows/docker.yml
permissions:
contents: read
packages: write

browser:
name: Browser flows and Slack notifications
needs: image
runs-on: ubuntu-latest
permissions:
contents: read
packages: read
env:
E2E_WEBSITE_IMAGE: ${{ needs.image.outputs.image }}
timeout-minutes: 25
steps:
- uses: actions/checkout@v7.0.1
- uses: actions/setup-node@v7.0.0
with:
node-version: 24.x
- run: npm install -g pnpm@12.3.4
- run: pnpm install --frozen-lockfile
- run: pnpm exec playwright install --with-deps chromium
- run: pnpm exec prisma generate
- run: pnpm typecheck
- name: Log in to GitHub Container Registry
uses: docker/login-action@v4.6.0
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- run: pnpm test:e2e
- uses: actions/upload-artifact@v7.0.1
if: failure()
with:
name: e2e-diagnostics
path: |
test-results/
playwright-report/
retention-days: 7

tested-image:
name: Mark the main image as tested
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
needs: [image, browser]
runs-on: ubuntu-latest
permissions:
packages: write
steps:
- uses: docker/setup-buildx-action@v4.3.0
- uses: docker/login-action@v4.6.0
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Tag the image that passed E2E
env:
SOURCE_IMAGE: ${{ needs.image.outputs.image }}
TESTED_IMAGE: ghcr.io/${{ github.repository_owner }}/chalmers.it:tested-${{ github.sha }}
run: docker buildx imagetools create --prefer-index=false --tag "$TESTED_IMAGE" "$SOURCE_IMAGE"
81 changes: 81 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
name: Release image

on:
release:
types: [published, released, edited]
workflow_dispatch:
inputs:
tag:
description: Existing published release tag to promote
required: true
type: string

permissions:
contents: read
packages: write

concurrency:
group: release-image
cancel-in-progress: false

jobs:
promote:
name: Promote the tested image
if: github.event_name == 'workflow_dispatch' || !github.event.release.draft
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7.0.1
with:
fetch-depth: 0
- uses: docker/setup-buildx-action@v4.3.0
- uses: docker/login-action@v4.6.0
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Retag the image that passed E2E on main
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
RELEASE_TAG: ${{ inputs.tag || github.event.release.tag_name }}
IMAGE: ghcr.io/${{ github.repository_owner }}/chalmers.it
run: |
if [[ ! "$RELEASE_TAG" =~ ^v?[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$ ]]; then
echo "::error::Expected a release tag such as v1.2.3 or v1.2.3-rc.1"
exit 1
fi

release=$(gh api "repos/$GITHUB_REPOSITORY/releases/tags/$RELEASE_TAG")
if ! jq -e '.draft == false' <<< "$release" > /dev/null; then
echo "::error::The release must be published before its image can be promoted"
exit 1
fi

commit=$(git rev-parse "refs/tags/$RELEASE_TAG^{commit}")
if ! git merge-base --is-ancestor "$commit" origin/main; then
echo "::error::The release commit must belong to main"
exit 1
fi

# This tag is created only after the main build passes E2E.
if ! digest=$(docker buildx imagetools inspect "$IMAGE:tested-$commit" --format '{{json .Manifest}}' | jq -er '.digest'); then
echo "::error::No tested image exists for $commit. Let main's E2E workflow pass, then rerun this workflow."
exit 1
fi

version=${RELEASE_TAG#v}
tags=(--tag "$IMAGE:$RELEASE_TAG")
if [[ "$version" != "$RELEASE_TAG" ]]; then
tags+=(--tag "$IMAGE:$version")
fi

# Only GitHub's latest stable release moves the rolling image tags.
if [[ "$version" != *-* ]] && jq -e '.prerelease == false' <<< "$release" > /dev/null; then
latest_id=$(gh api "repos/$GITHUB_REPOSITORY/releases/latest" --jq '.id')
if [[ "$latest_id" == "$(jq -r '.id' <<< "$release")" ]]; then
IFS=. read -r major minor _ <<< "$version"
tags+=(--tag "$IMAGE:$major" --tag "$IMAGE:$major.$minor" --tag "$IMAGE:latest")
fi
fi

docker buildx imagetools create --prefer-index=false "${tags[@]}" "$IMAGE@$digest"
echo "Promoted $IMAGE@$digest to ${tags[*]}" >> "$GITHUB_STEP_SUMMARY"
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -40,4 +40,7 @@ package-lock.json
.idea/
/db

/test-results/
/gamma-images/

/playwright-report/
19 changes: 19 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,3 +96,22 @@ The following environment variables are used:
| PAGE_EDITOR_GROUPS | Comma-separated list of groups that are allowed to edit division pages in addition to admins | `snit,motespresidit` |
| CORPORATE_RELATIONS_GROUP | Group that is considered the corporate relations group | `armit` |
| MAX_PAGE_SIZE | Max page size of paginated API endpoints | `50` |

## End-to-end tests

The Playwright suite lives in [`e2e/`](e2e/README.md). It covers real Gamma
login/logout and committee data, news and page creation with both language versions,
file uploads, news search, and Slack Markdown conversion and webhook fallback.

With Node.js 24, pnpm 12.3.4 and Docker running:

```sh
pnpm install --frozen-lockfile
pnpm exec playwright install chromium
pnpm test:e2e
```

Testcontainers starts Gamma, Redis and two fresh PostgreSQL databases. Tests use
a temporary media directory and a local Slack webhook receiver. No real Slack
credentials are needed. See [the E2E guide](e2e/README.md) for isolation, diagnostics
and the limits of local Slack validation. `pnpm test` runs Jest independently.
112 changes: 112 additions & 0 deletions e2e/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
# End-to-end tests

Run from the repository root with Node.js 24, pnpm 12.3.4 and a running Docker engine:

```sh
pnpm install --frozen-lockfile
pnpm exec playwright install chromium
pnpm test:e2e
```

`test:e2e` generates the Prisma client before starting Playwright.
The separate `End-to-end tests` GitHub Actions workflow runs on pull requests
targeting `main` and pushes to `main`, using the same Node.js and pnpm versions.
It calls the Docker publishing workflow, then passes the published image's immutable
digest to the browser job through `E2E_WEBSITE_IMAGE`. The browser job checks TypeScript
before starting that production image with Testcontainers. CI requires this image
reference and never falls back to a development server.

After E2E passes on `main`, the workflow tags that same digest as `tested-<full commit SHA>`.
Publishing a GitHub release promotes the tested image for its tag's commit without
rebuilding. For `v1.2.3`, it adds both `v1.2.3` and `1.2.3`. Only GitHub's latest stable
release also updates `1`, `1.2`, and `latest`; prereleases and older releases keep those
rolling tags unchanged. Editing a release or promoting it from prerelease runs the
promotion again, so changing GitHub's latest release updates the image tags too.

The release commit must belong to `main` and have a tested image. If E2E has not passed
yet, release promotion fails; rerun it after E2E passes, or run `Release image` manually
with the existing release tag. Pushing a Git tag alone does not publish a release image.

The image uses host networking on the isolated Linux runner, sharing loopback URLs
with the browser, Gamma and the Slack receiver. It listens on port 3000 and runs its
normal database migrations before the suite seeds the committee mapping. Uploaded
files live in the disposable container. Local runs use the development server unless
`E2E_WEBSITE_IMAGE` is set; image runs require Docker host networking and a free port 3000.

The workspace records explicit build-script decisions for pnpm 12 so dependency
builds run without interactive approval.

To select tests:

```sh
pnpm test:e2e e2e/gamma.spec.ts
pnpm test:e2e e2e/content.spec.ts
pnpm test:e2e e2e/slack.spec.ts
```

## Coverage

| File | Flows and assertions |
| ----------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `gamma.spec.ts` | Website login → real Gamma → OAuth callback → authenticated session; committee member and role from Gamma; logout clears the session and authoring access. |
| `content.spec.ts` | Create news and division pages through the English UI, verify both saved language versions and reload persistence; upload and embed an image in each, verify downloaded bytes; search title/body, follow results, handle empty results and short queries. |
| `slack.spec.ts` | Publish predefined Markdown through the website; capture the outgoing webhook; validate block structure and preserved text; compare with the Slack serialization API in English and Swedish; check long headers and HTTP 400 fallback. |

## Writing tests

Use blank lines to separate setup, actions, and assertions. Longer journeys use
`test.step()` for meaningful phases that also appear in the Playwright report.
Keep assertions in the specs and reusable browser actions in `helpers/browser.ts`.
`fixtures/` holds the Gamma bootstrap, Markdown examples, and upload bytes;
`helpers/gamma.ts` provisions the official OAuth client through Gamma's UI.

Prefer `getByRole()` with an accessible name for interactive controls. Use a
label or placeholder when a role cannot identify the control, and text locators
for plain content. Authoring fields currently use headings without associated
labels, so the helper scopes by heading before selecting the control by role.
The website's logout anchor uses visible text because it has no `href` or
link role. The unnamed search textbox is distinguished from date filters by
its native input type. Uploads use the visible “Select files” button and its
file chooser.

## Isolation

`composer.ts` starts a single stack per Playwright worker using Testcontainers:

- Gamma **2.5.1**, pinned by digest, with test-only users and committee data from its real bootstrap.
- Redis 5.0.14-alpine and two PostgreSQL 16.0-alpine containers for Gamma and the website.
- The published production image in CI, or the Next.js development server on an available local port locally.
- A temporary directory for uploaded files and a loopback HTTP receiver for Slack webhooks.

Gamma's official OAuth client is provisioned through its administrator UI. The website
database initially contains only a committee mapping; member names and roles come from
Gamma. Each test gets a fresh browser context and starts with empty news, pages, media
metadata and notifier tables. No existing database or Slack webhook is used. Tests
run sequentially because they share this disposable stack. Uploads, containers and the
network are removed on teardown, including failures; Testcontainers' resource reaper
handles containers if the runner is forcibly terminated. The Gamma image needs amd64
emulation on ARM machines.

## Slack checks

The local receiver checks the Block Kit subset this application's converter emits:
required fields, header/text limits, absolute image/link URLs, block counts, and valid
rich-text nesting. The contract is based on Slack's [block reference](https://docs.slack.dev/reference/block-kit/blocks/),
[rich text](https://docs.slack.dev/reference/block-kit/blocks/rich-text-block/), and
[header limits](https://docs.slack.dev/reference/block-kit/blocks/header-block/).

Invalid initial payloads fail the tests even if the website sends a successful fallback.
The fallback test intentionally returns `400 invalid_blocks` once and verifies the
second payload. Payloads are never posted to Slack. These local contract checks do not
verify Slack credentials, workspace policies, or every rule enforced by Slack's API.

## Diagnostics

Failures retain screenshots, Playwright traces, Gamma/website logs and captured webhook
JSON under `test-results/`. Startup failures write `startup-<worker>.log` there.
`playwright-report/` contains the HTML report. CI uploads both directories on failure.

```sh
pnpm exec playwright show-report
pnpm exec playwright show-trace test-results/<test>/trace.zip
```
Loading