Skip to content
Merged
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
63 changes: 58 additions & 5 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -1178,14 +1178,63 @@ jobs:
use-artifact: ${{ steps.strategy.outputs.use-artifact }}

job_e2e_tests:
name: E2E Tests (${{ matrix.shardIndex }}/${{ matrix.shardTotal }})
name: E2E Tests (${{ matrix.projectName }} ${{ matrix.shardIndex }}/${{ matrix.shardTotal }})
runs-on: ubuntu-latest
needs: [job_build_e2e_image, job_setup]
strategy:
fail-fast: true
matrix:
shardIndex: [1, 2, 3, 4, 5, 6, 7, 8]
shardTotal: [8]
include:
- projectName: Main
projects: main
analytics: 'false'
shardIndex: 1
shardTotal: 8
- projectName: Main
projects: main
analytics: 'false'
shardIndex: 2
shardTotal: 8
- projectName: Main
projects: main
analytics: 'false'
shardIndex: 3
shardTotal: 8
- projectName: Main
projects: main
analytics: 'false'
shardIndex: 4
shardTotal: 8
- projectName: Main
projects: main
analytics: 'false'
shardIndex: 5
shardTotal: 8
- projectName: Main
projects: main
analytics: 'false'
shardIndex: 6
shardTotal: 8
- projectName: Main
projects: main
analytics: 'false'
shardIndex: 7
shardTotal: 8
- projectName: Main
projects: main
analytics: 'false'
shardIndex: 8
shardTotal: 8
- projectName: Analytics
projects: analytics
analytics: 'true'
shardIndex: 1
shardTotal: 2
- projectName: Analytics
projects: analytics
analytics: 'true'
shardIndex: 2
shardTotal: 2
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
Expand All @@ -1197,6 +1246,7 @@ jobs:
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4

- name: Pull or build Tinybird CLI Image
if: matrix.analytics == 'true'
run: |
COMPOSE_IMAGE="${COMPOSE_PROJECT_NAME:-ghost-dev}-tb-cli"
# Try pulling pre-built image from GHCR first (fast path)
Expand Down Expand Up @@ -1229,13 +1279,16 @@ jobs:
env:
GHOST_E2E_IMAGE: ${{ steps.load.outputs.image-tag }}
GHOST_E2E_SKIP_IMAGE_BUILD: 'true'
GHOST_E2E_ANALYTICS: ${{ matrix.analytics }}
run: bash ./e2e/scripts/prepare-ci-e2e-job.sh

- name: Run e2e tests in Playwright container
env:
TEST_WORKERS_COUNT: 1
GHOST_E2E_MODE: build
GHOST_E2E_IMAGE: ${{ steps.load.outputs.image-tag }}
GHOST_E2E_ANALYTICS: ${{ matrix.analytics }}
E2E_PLAYWRIGHT_PROJECTS: ${{ matrix.projects }}
E2E_SHARD_INDEX: ${{ matrix.shardIndex }}
E2E_SHARD_TOTAL: ${{ matrix.shardTotal }}
E2E_RETRIES: 2
Expand All @@ -1253,15 +1306,15 @@ jobs:
if: failure()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: blob-report-${{ matrix.shardIndex }}
name: blob-report-${{ matrix.projectName }}-${{ matrix.shardIndex }}
path: e2e/blob-report
retention-days: 1

- name: Upload test results artifacts
if: failure()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: test-results-${{ matrix.shardIndex }}
name: test-results-${{ matrix.projectName }}-${{ matrix.shardIndex }}
path: e2e/test-results
retention-days: 7

Expand Down
20 changes: 20 additions & 0 deletions apps/admin-x-framework/src/api/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,26 @@ export const useEditSettings = createMutation<SettingsResponseType, Setting[]>({
}
});

export const useRegenerateAccessCode = createMutation<SettingsResponseType, null>({
method: 'POST',
path: () => '/settings/access_code/regenerate/',
updateQueries: {
dataType,
emberUpdateType: 'createOrUpdate',
update: newData => ({
...newData,
settings: newData.settings
})
},
invalidateQueries: {
filters: {
predicate(query) {
return query.queryKey[0] !== dataType;
}
}
}
});

export const useDeleteStripeSettings = createMutation<unknown, null>({
method: 'DELETE',
path: () => '/settings/stripe/connect/',
Expand Down
5 changes: 4 additions & 1 deletion apps/admin-x-framework/src/test/msw-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,10 @@ export const fixtures = {
codeinjection_head: null,
codeinjection_foot: null,
navigation: [],
secondary_navigation: []
secondary_navigation: [],
llms_enabled: true,
meta_title: null,
meta_description: null
},

site: {
Expand Down
4 changes: 4 additions & 0 deletions apps/admin-x-framework/src/test/responses/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,10 @@
"key": "secondary_navigation",
"value": "[{\"label\":\"Sign up\",\"url\":\"#/portal/\"}]"
},
{
"key": "llms_enabled",
"value": true
},
{
"key": "meta_title",
"value": null
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import {Banner, Button as ShadeButton} from '@tryghost/shade/components';
import {type GroupBase, type MultiValue} from 'react-select';
import {Hint, MultiSelect, type MultiSelectOption, Select, Separator, SettingGroupContent, TextField, showToast, withErrorBoundary} from '@tryghost/admin-x-design-system';
import {RefreshCw} from 'lucide-react';
import {getSettingValues, isSettingReadOnly, useEditSettings} from '@tryghost/admin-x-framework/api/settings';
import {getSettingValues, isSettingReadOnly, useRegenerateAccessCode} from '@tryghost/admin-x-framework/api/settings';
import {useBrowseTiers} from '@tryghost/admin-x-framework/api/tiers';
import {useGlobalData} from '../../providers/global-data-provider';
import {useLimiter} from '../../../hooks/use-limiter';
Expand Down Expand Up @@ -92,7 +92,7 @@ const Access: React.FC<{ keywords: string[] }> = ({keywords}) => {
const limiter = useLimiter();
const isTrialMode = limiter?.isDisabled('publicSiteAccess');
const isPrivateLocked = isSettingReadOnly(settings, 'is_private') || isSettingReadOnly(settings, 'password');
const {mutateAsync: editSettings} = useEditSettings();
const {mutateAsync: regenerateAccessCode} = useRegenerateAccessCode();
const [isRegenerating, setIsRegenerating] = React.useState(false);
const {
localSettings,
Expand Down Expand Up @@ -148,7 +148,13 @@ const Access: React.FC<{ keywords: string[] }> = ({keywords}) => {
const handleRegenerateAccessCode = async () => {
setIsRegenerating(true);
try {
await editSettings([{key: 'password', value: null}]);
const response = await regenerateAccessCode(null);
const regeneratedAccessCode = response.settings.find(setting => setting.key === 'password')?.value;

if (typeof regeneratedAccessCode === 'string') {
updateSetting('password', regeneratedAccessCode);
clearError('password');
}
} catch {
showToast({
type: 'error',
Expand Down Expand Up @@ -204,6 +210,7 @@ const Access: React.FC<{ keywords: string[] }> = ({keywords}) => {
<button
aria-label='Regenerate access code'
className='mr-[5px] flex size-5 cursor-pointer items-center justify-center p-0 text-grey-900 disabled:cursor-not-allowed disabled:opacity-40 dark:text-grey-400'
data-testid='regenerate-access-code'
disabled={isRegenerating}
title='Regenerate access code'
type='button'
Expand Down
54 changes: 53 additions & 1 deletion apps/admin-x-settings/test/acceptance/membership/access.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,19 @@
import {chooseOptionInSelect, getOptionsFromSelect, globalDataRequests, mockApi, responseFixtures, updatedSettingsResponse} from '@tryghost/admin-x-framework/test/acceptance';
import {chooseOptionInSelect, getOptionsFromSelect, globalDataRequests, mockApi, responseFixtures, updatedSettingsResponse, waitForApiRequest} from '@tryghost/admin-x-framework/test/acceptance';
import {expect, test} from '@playwright/test';

const createConfigWithLimits = (limits: Record<string, unknown>) => ({
...globalDataRequests.browseConfig,
response: {
config: {
...responseFixtures.config.config,
hostSettings: {
...responseFixtures.config.config.hostSettings,
limits
}
}
}
});

test.describe('Access settings', async () => {
test('Supports switching site visibility between public and private', async ({page}) => {
const mockLock = await mockApi({page, requests: {
Expand Down Expand Up @@ -102,6 +115,45 @@ test.describe('Access settings', async () => {
});
});

test('Regenerates locked private-site access code server-side', async ({page}) => {
const {lastApiRequests} = await mockApi({page, requests: {
...globalDataRequests,
browseConfig: createConfigWithLimits({
publicSiteAccess: {
disabled: true,
error: 'This plan does not include public site access'
}
}),
browseSettings: {
...globalDataRequests.browseSettings,
response: updatedSettingsResponse([
{key: 'is_private', value: true, is_read_only: true},
{key: 'password', value: 'fake-123', is_read_only: true}
])
},
regenerateAccessCode: {
method: 'POST',
path: '/settings/access_code/regenerate/',
response: updatedSettingsResponse([
{key: 'is_private', value: true, is_read_only: true},
{key: 'password', value: 'fake-456', is_read_only: true}
])
}
}});

await page.goto('/');

const accessSection = page.getByTestId('access');
const accessCode = accessSection.getByTestId('site-access-code');

await expect(accessCode).toHaveValue('fake-123');
await accessSection.getByTestId('regenerate-access-code').click();

const request = await waitForApiRequest(lastApiRequests, 'regenerateAccessCode');
expect(request.body).toBeNull();
await expect(accessCode).toHaveValue('fake-456');
});

test('Disables other sections when signup is disabled', async ({page}) => {
const {lastApiRequests} = await mockApi({page, requests: {
...globalDataRequests,
Expand Down
6 changes: 3 additions & 3 deletions e2e/helpers/environment/environment-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,9 @@ type GhostEnvOverrides = GhostConfig | Record<string, string>;
* - dev: Uses dev infrastructure with hot-reloading
* - build: Uses pre-built image (set GHOST_E2E_IMAGE for registry images)
*
* All modes use the same infrastructure (MySQL, Redis, Mailpit, Tinybird)
* started via docker compose. Ghost and gateway containers are created
* dynamically per-worker for test isolation.
* All modes use the same core infrastructure (MySQL, Redis, Mailpit) started
* via docker compose. Analytics/Tinybird services are optional. Ghost and
* gateway containers are created dynamically per-worker for test isolation.
*/
export class EnvironmentManager {
private readonly mode: EnvironmentMode;
Expand Down
5 changes: 5 additions & 0 deletions e2e/helpers/environment/service-availability.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,11 @@ async function isServiceAvailable(docker: Docker, serviceName: string) {
* Checks for tinybird-local service in ghost-dev compose project.
*/
export async function isTinybirdAvailable(): Promise<boolean> {
if (process.env.GHOST_E2E_ANALYTICS === 'false') {
debug('Tinybird disabled by GHOST_E2E_ANALYTICS=false');
return false;
}

const docker = new Docker();
const tinybirdAvailable = await isServiceAvailable(docker, TINYBIRD.LOCAL_HOST);
debug(`Tinybird availability for compose project ${DEV_ENVIRONMENT.projectNamespace}:`, tinybirdAvailable);
Expand Down
6 changes: 4 additions & 2 deletions e2e/scripts/infra-down.sh
Original file line number Diff line number Diff line change
Expand Up @@ -6,5 +6,7 @@ REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"

cd "$REPO_ROOT"

docker compose -f compose.dev.yaml -f compose.dev.analytics.yaml stop \
analytics tb-cli tinybird-local mailpit redis mysql
compose_files=(-f compose.dev.yaml -f compose.dev.analytics.yaml)
services=(analytics tb-cli tinybird-local mailpit redis mysql)

docker compose "${compose_files[@]}" stop "${services[@]}"
12 changes: 10 additions & 2 deletions e2e/scripts/infra-up.sh
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ cd "$REPO_ROOT"

MODE="$(resolve_e2e_mode)"
export GHOST_E2E_MODE="$MODE"
ANALYTICS_ENABLED="${GHOST_E2E_ANALYTICS:-true}"

if [[ "$MODE" != "build" ]]; then
DEV_COMPOSE_PROJECT="${COMPOSE_PROJECT_NAME:-ghost-dev}"
Expand All @@ -21,5 +22,12 @@ if [[ "$MODE" != "build" ]]; then
fi
fi

docker compose -f compose.dev.yaml -f compose.dev.analytics.yaml up -d --wait \
mysql redis mailpit tinybird-local analytics
compose_files=(-f compose.dev.yaml)
services=(mysql redis mailpit)

if [[ "$ANALYTICS_ENABLED" == "true" ]]; then
compose_files+=(-f compose.dev.analytics.yaml)
services+=(tinybird-local analytics)
fi

docker compose "${compose_files[@]}" up -d --wait "${services[@]}"
8 changes: 6 additions & 2 deletions e2e/scripts/prepare-ci-e2e-build-mode.sh
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,12 @@ set -euo pipefail

source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/load-playwright-container-env.sh"
GATEWAY_IMAGE="${GHOST_E2E_GATEWAY_IMAGE:-caddy:2-alpine}"
ANALYTICS_ENABLED="${GHOST_E2E_ANALYTICS:-true}"

echo "Preparing E2E build-mode runtime"
echo "Playwright image: ${PLAYWRIGHT_IMAGE}"
echo "Gateway image: ${GATEWAY_IMAGE}"
echo "Analytics enabled: ${ANALYTICS_ENABLED}"

pids=()
labels=()
Expand All @@ -25,7 +27,7 @@ run_bg() {

run_bg "pull-gateway-image" docker pull "$GATEWAY_IMAGE"
run_bg "pull-playwright-image" docker pull "$PLAYWRIGHT_IMAGE"
run_bg "start-infra" env GHOST_E2E_MODE=build bash "$REPO_ROOT/e2e/scripts/infra-up.sh"
run_bg "start-infra" env GHOST_E2E_MODE=build GHOST_E2E_ANALYTICS="$ANALYTICS_ENABLED" bash "$REPO_ROOT/e2e/scripts/infra-up.sh"

for i in "${!pids[@]}"; do
if ! wait "${pids[$i]}"; then
Expand All @@ -34,4 +36,6 @@ for i in "${!pids[@]}"; do
fi
done

node "$REPO_ROOT/e2e/scripts/sync-tinybird-state.mjs"
if [[ "$ANALYTICS_ENABLED" == "true" ]]; then
node "$REPO_ROOT/e2e/scripts/sync-tinybird-state.mjs"
fi
20 changes: 19 additions & 1 deletion e2e/scripts/run-playwright-container.sh
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ set -euo pipefail
SHARD_INDEX="${E2E_SHARD_INDEX:-}"
SHARD_TOTAL="${E2E_SHARD_TOTAL:-}"
RETRIES="${E2E_RETRIES:-2}"
PROJECTS="${E2E_PLAYWRIGHT_PROJECTS:-main,analytics}"

if [[ -z "$SHARD_INDEX" || -z "$SHARD_TOTAL" ]]; then
echo "Missing E2E_SHARD_INDEX or E2E_SHARD_TOTAL environment variables" >&2
Expand All @@ -12,6 +13,22 @@ fi

source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/load-playwright-container-env.sh"

project_args=()
IFS=',' read -ra project_names <<< "$PROJECTS"
for project in "${project_names[@]}"; do
project="${project//[[:space:]]/}"
if [[ -n "$project" ]]; then
project_args+=("--project=${project}")
fi
done

if [[ ${#project_args[@]} -eq 0 ]]; then
echo "No Playwright projects configured in E2E_PLAYWRIGHT_PROJECTS" >&2
exit 1
fi

printf -v project_args_string '%q ' "${project_args[@]}"

docker run --rm --network host --ipc host \
-v /var/run/docker.sock:/var/run/docker.sock \
-v "${WORKSPACE_PATH}:${WORKSPACE_PATH}" \
Expand All @@ -22,5 +39,6 @@ docker run --rm --network host --ipc host \
-e GHOST_E2E_MODE="${GHOST_E2E_MODE:-build}" \
-e GHOST_E2E_IMAGE="${GHOST_E2E_IMAGE:-ghost-e2e:local}" \
-e GHOST_E2E_GATEWAY_IMAGE="${GHOST_E2E_GATEWAY_IMAGE:-caddy:2-alpine}" \
-e GHOST_E2E_ANALYTICS="${GHOST_E2E_ANALYTICS:-true}" \
"$PLAYWRIGHT_IMAGE" \
bash -c "corepack enable && pnpm test:all --shard=${SHARD_INDEX}/${SHARD_TOTAL} --retries=${RETRIES}"
bash -c "corepack enable && bash ./scripts/run-playwright-host.sh pnpm exec playwright test ${project_args_string}--shard=${SHARD_INDEX}/${SHARD_TOTAL} --retries=${RETRIES}"
Loading
Loading