diff --git a/.devcontainer/compose.devcontainer.yaml b/.devcontainer/compose.devcontainer.yaml index b9f792c02b5..6f5a50eab23 100644 --- a/.devcontainer/compose.devcontainer.yaml +++ b/.devcontainer/compose.devcontainer.yaml @@ -2,10 +2,18 @@ services: mysql: # Smaller InnoDB buffer pool than the baseline compose.dev.yaml (1G). # Lets the dev stack stay within a 2-core / 8 GB Codespace's budget - # after Ghost + 6 Vite dev servers are running. 256 MB is plenty for - # dev data volumes. + # after Ghost + the default Admin/Portal dev watchers are running. + # 256 MB is plenty for dev data volumes. command: --innodb-buffer-pool-size=256M --innodb-log-buffer-size=64M --innodb-flush-log-at-trx_commit=0 --innodb-flush-method=O_DIRECT - mem_limit: 512m + # mem_limit is a hard cgroup ceiling, separate from the buffer pool size + # above. Measured via `docker stats` under real usage: actual RSS sits + # around 440-460MiB, so the previous 512m cap left MySQL pinned at + # 85-90% of its ceiling continuously (real, not just at boot) -- more + # memory-management overhead on every query than the buffer pool size + # alone would suggest. 1g (matching host-hybrid's unconstrained default) + # gives real headroom above the actual working set, still a small slice + # even on the 2-core/8GB minimum tier. + mem_limit: 1g restart: unless-stopped redis: @@ -19,8 +27,28 @@ services: # The original ./ghost:/home/ghost/ghost mount from compose.dev.yaml is # preserved via merge semantics so nodemon hot-reload still works for anyone # running the backend the original way. + # + # pnpm-store is a separate named volume, NOT under /workspaces/Ghost, so + # it survives the bind mount above (which fully shadows anything baked + # into the image at that path with the host's — node_modules-less — repo + # checkout). Because the source is live-mounted, onCreateCommand still has + # to run a real `pnpm install` every time, but with the store already warm + # it's linking already-fetched content instead of + # re-downloading and recompiling native deps (better-sqlite3, sharp, etc.) + # from scratch. volumes: - ./:/workspaces/Ghost:cached + - pnpm-store:/pnpm-store + environment: + pnpm_config_store_dir: /pnpm-store + # Prefer the prebuilt image (docker/ghost-dev/Dockerfile, published by + # .github/workflows/devcontainer-build.yml) over a local build. cache_from + # makes a local `docker compose build` (e.g. after a Dockerfile edit) + # reuse those layers too, rather than starting from zero. + image: ghcr.io/tryghost/ghost-devcontainer:latest + build: + cache_from: + - ghcr.io/tryghost/ghost-devcontainer:latest # Override the default `pnpm dev` command. VS Code controls startup and the # user runs backend/frontend dev servers as tasks (see .vscode/tasks.json). command: ["sleep", "infinity"] @@ -38,15 +66,15 @@ services: ghost-dev-gateway: # Point Caddy at the dev servers running inside the ghost-dev container # instead of host.docker.internal (which is the default for the hybrid - # host/container dev setup). + # host/container dev setup). Public apps (Portal, Comments UI, Signup + # Form, Sodo Search, Announcement Bar, Admin Toolbar) have no entry here + # — Caddy serves their umd/ build output directly via file_server, so + # there's no dev-server host:port to point at. environment: GHOST_BACKEND: ghost-dev:2368 ADMIN_DEV_SERVER: ghost-dev:5174 ADMIN_LIVE_RELOAD_SERVER: ghost-dev:4200 - PORTAL_DEV_SERVER: ghost-dev:4175 - COMMENTS_DEV_SERVER: ghost-dev:7173 - SIGNUP_DEV_SERVER: ghost-dev:6174 - SEARCH_DEV_SERVER: ghost-dev:4178 - ANNOUNCEMENT_DEV_SERVER: ghost-dev:4177 - ADMIN_TOOLBAR_DEV_SERVER: ghost-dev:4176 LEXICAL_DEV_SERVER: ghost-dev:4173 + +volumes: + pnpm-store: diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index b260a719efb..82eb626fc94 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -23,13 +23,21 @@ // dev containers where the host is the developer's own machine, but do // NOT replicate this pattern in shared build agents, CI runners, or // environments where untrusted code runs in the container. + // sshd enables `gh codespace ssh`/`logs` for direct diagnosis (top, + // free, docker stats) instead of only being able to probe over HTTP. + // Same trust boundary as docker-outside-of-docker below — standard for + // a Codespace/local dev container where the host is your own machine. "features": { - "ghcr.io/devcontainers/features/docker-outside-of-docker:1": {} + "ghcr.io/devcontainers/features/docker-outside-of-docker:1": {}, + "ghcr.io/devcontainers/features/sshd:1": {} }, - // Codespaces prebuild step — runs once when the image is built. - // Primes the pnpm store so first-open is fast. - "onCreateCommand": "corepack enable && corepack prepare --activate && cd /workspaces/Ghost && pnpm install --prefer-offline || true", + // Runs once the workspace mount is ready, after the container is created. + // Note: no Codespaces prebuild is configured for this repo, so this runs + // live on every creation rather than being baked into a snapshot ahead of + // time. A failed install must stop setup because postCreateCommand assumes + // workspace dependencies are available. + "onCreateCommand": "corepack enable && corepack prepare --activate && cd /workspaces/Ghost && pnpm install --prefer-offline", // Runs after the workspace mount is ready on every container create. "postCreateCommand": ".devcontainer/postCreate.sh", @@ -40,6 +48,10 @@ // The script guards against double-starting if the stack is already up. "postAttachCommand": "bash .devcontainer/start-dev-stack.sh", + // Portal, Comments UI, Signup Form, Sodo Search, Announcement Bar, and + // Admin Toolbar are NOT listed here — since the Caddy file_server switch + // (#28970) they build to disk (`vite build --watch`) instead of running + // a dev server, so there's no port to forward for them. "forwardPorts": [ 2368, 3306, @@ -48,11 +60,6 @@ 8025, 5174, 4200, - 4175, - 7173, - 6174, - 4178, - 4177, 4173 ], "portsAttributes": { @@ -63,12 +70,7 @@ "8025": {"label": "Mailpit Web"}, "5174": {"label": "Admin (Vite)"}, "4200": {"label": "Ember live-reload"}, - "4175": {"label": "Portal"}, - "7173": {"label": "Comments UI"}, - "6174": {"label": "Signup Form"}, - "4178": {"label": "Sodo Search"}, - "4177": {"label": "Announcement Bar"}, - "4173": {"label": "Koenig Lexical"} + "4173": {"label": "Koenig Lexical (optional)"} }, "customizations": { diff --git a/.devcontainer/postCreate.sh b/.devcontainer/postCreate.sh index 6d74af239e1..df82d4aadbf 100755 --- a/.devcontainer/postCreate.sh +++ b/.devcontainer/postCreate.sh @@ -8,7 +8,10 @@ corepack prepare --activate git submodule update --init --recursive -pnpm install --prefer-offline +# pnpm install already ran in onCreateCommand against this same fully-mounted +# workspace (submodules are theme content, not workspace packages, so their +# absence above didn't affect that install) — a second pass here would just +# re-walk the whole dependency graph for nothing. # Build workspace packages that ghost/core imports at runtime with build # outputs (not source). @tryghost/parse-email-address is the only one today diff --git a/.devcontainer/start-dev-stack.sh b/.devcontainer/start-dev-stack.sh index 40792931c6f..ed5ef2e15d4 100755 --- a/.devcontainer/start-dev-stack.sh +++ b/.devcontainer/start-dev-stack.sh @@ -13,6 +13,38 @@ fi echo "Starting Ghost dev stack..." +# Ghost's own `url` config (default http://localhost:2368) is what session +# CSRF checks compare the browser's Origin header against (see +# cookieCsrfProtection in ghost/core/core/server/services/auth/session/ +# session-service.js). Codespaces serves everything through a forwarded +# https://-2368. origin instead, so without this every +# authenticated admin request fails that origin check and bounces back to +# login. `url` is a top-level nconf key, so a bare `url` env var overrides +# it (see ghost/core/core/shared/config/loader.js's nconf.env() call). +# Local (non-Codespaces) VS Code Dev Containers forward to genuine +# localhost, so this only applies inside Codespaces itself. +if [ -n "${CODESPACES:-}" ] && [ -n "${CODESPACE_NAME:-}" ]; then + export url="https://${CODESPACE_NAME}-2368.${GITHUB_CODESPACES_PORT_FORWARDING_DOMAIN:-app.github.dev}" + echo "Codespaces detected: setting Ghost url=$url so admin session origin checks match the forwarded tunnel" >> /tmp/ghost-backend.log + + # Once url above is HTTPS, Ghost's url-redirects middleware + # (getAdminRedirectUrl in ghost/core/core/server/web/shared/middleware/ + # url-redirects.js) 301s any request it sees as insecure (protocol + # mismatch) to that HTTPS url. Real browser traffic is fine — Caddy sets + # X-Forwarded-Proto: https, so Express's req.secure is true. But + # apps/admin's own vite dev server bootstraps itself by fetching + # GHOST_URL + ghost/api/admin/site/ directly (vite-backend-proxy.ts), + # bypassing Caddy entirely by default (http://localhost:2368) — Express + # sees that as insecure, 301s it to the public tunnel URL, and since + # that's an external, GitHub-auth-gated address, Vite's plain fetch() + # can't complete the login flow and crashes on the HTML it gets back + # instead of JSON. Routing GHOST_URL through the gateway container + # instead keeps the request on the internal Docker network (still gets + # X-Forwarded-Proto: https from Caddy, so no redirect) while never + # touching the external, auth-gated tunnel at all. + export GHOST_URL="http://ghost-dev-gateway:80/" +fi + # Append to log files (don't truncate) so previous crash tails survive a # restart and the user can still tail them for context. { echo "=== $(date -Is) starting backend ==="; } >> /tmp/ghost-backend.log @@ -20,8 +52,12 @@ nohup pnpm --filter ghost dev >> /tmp/ghost-backend.log 2>&1 & disown { echo "=== $(date -Is) starting frontends ==="; } >> /tmp/ghost-frontends.log +# Matches root `pnpm dev`'s default fan-out (Admin + Portal only) — most +# devcontainer sessions never touch the public UMD apps, and those watchers +# are the heaviest processes in the stack. To also start them, run e.g.: +# pnpm nx run-many -t dev --projects=@tryghost/comments-ui,@tryghost/signup-form,@tryghost/sodo-search,@tryghost/announcement-bar,@tryghost/admin-toolbar nohup pnpm nx run-many -t dev \ - --projects=@tryghost/admin,@tryghost/portal,@tryghost/comments-ui,@tryghost/signup-form,@tryghost/sodo-search,@tryghost/announcement-bar \ + --projects=@tryghost/admin,@tryghost/portal \ >> /tmp/ghost-frontends.log 2>&1 & disown @@ -33,4 +69,8 @@ Ghost dev stack starting in the background. Gateway: http://localhost:2368/ Give it ~30-60s, then open http://localhost:2368/ghost/ for admin. + +Only Admin + Portal dev watchers start by default. To add the public UMD +apps (Comments, Signup Form, Sodo Search, Announcement Bar, Admin Toolbar): + pnpm nx run-many -t dev --projects=@tryghost/comments-ui,@tryghost/signup-form,@tryghost/sodo-search,@tryghost/announcement-bar,@tryghost/admin-toolbar MSG diff --git a/apps/admin/src/settings/membership/custom-fields.acceptance.test.tsx b/apps/admin/src/settings/membership/custom-fields.acceptance.test.tsx index 9b55efda1f1..ac1e2044275 100644 --- a/apps/admin/src/settings/membership/custom-fields.acceptance.test.tsx +++ b/apps/admin/src/settings/membership/custom-fields.acceptance.test.tsx @@ -30,10 +30,31 @@ function customFieldsBoot() { }; } -function fakeCustomFields(fields = [companyField]) { - // Settings opts into archived fields (`?filter=status:[active,archived]`), - // so the fake matches the path with any query. - return fakeAdminEndpoint("GET", new RegExp("^/members/custom_fields/\\?"), {members_custom_fields: fields}); +type CustomField = typeof companyField; + +// Settings opts into archived fields (`?filter=status:[active,archived]`), +// so the fakes match the path with any query. +const customFieldsBrowsePath = new RegExp("^/members/custom_fields/\\?"); + +function fakeCustomFields(fields: CustomField[] = [companyField]) { + return fakeAdminEndpoint("GET", customFieldsBrowsePath, {members_custom_fields: fields}); +} + +/** + * Mutations invalidate and refetch the list, so a spec observing the list + * after a create serves it from state that grows when the POST lands; the + * created entity is declared by the spec, the fake invents nothing. + * Post-mutation outcomes of edits and deletes are server behavior, owned by + * the API suite (ghost/core e2e-api member-custom-fields) — those specs + * assert the outgoing request and the refetch instead. + */ +function fakeCustomFieldsWithCreate(initial: CustomField[], created: CustomField) { + let fields = initial; + fakeAdminEndpoint("GET", customFieldsBrowsePath, () => ({members_custom_fields: fields})); + return fakeAdminEndpoint("POST", "/members/custom_fields/", () => { + fields = [...fields, created]; + return {members_custom_fields: [created]}; + }); } describe("Custom fields", () => { @@ -202,17 +223,12 @@ describe("Custom fields", () => { it("reveals a just-created field even when the list is collapsed", async () => { fakeSettingsScreens(); - let currentFields = Array.from({length: 6}, (_, index) => ({ + const initialFields = Array.from({length: 6}, (_, index) => ({ ...companyField, key: `field-${index}`, name: `Field ${index}`, })); - fakeAdminEndpoint("GET", new RegExp("^/members/custom_fields/\\?"), () => ({members_custom_fields: currentFields})); - fakeAdminEndpoint("POST", "/members/custom_fields/", () => { - const created = {...companyField, key: "newest", name: "Newest"}; - currentFields = [...currentFields, created]; - return {members_custom_fields: [created]}; - }); + fakeCustomFieldsWithCreate(initialFields, {...companyField, key: "newest", name: "Newest"}); await renderAdminApp("/settings", {boot: customFieldsBoot()}); const rows = settingsScreen.customFields().getByTestId("custom-field-list-item"); @@ -237,7 +253,7 @@ describe("Custom fields", () => { it("permanently deletes an archived field from the header menu, after a heavy warning", async () => { fakeSettingsScreens(); - fakeCustomFields([companyField, archivedField]); + const customFieldsApi = fakeCustomFields([companyField, archivedField]); const deleteApi = fakeAdminEndpoint("DELETE", "/members/custom_fields/old-hobby/", {}); await renderAdminApp("/settings", {boot: customFieldsBoot()}); @@ -251,10 +267,15 @@ describe("Custom fields", () => { const confirmation = settingsScreen.confirmationModal(); await expect.element(confirmation).toHaveTextContent("Old hobby and every value collected from your members will be permanently deleted from the database. This can’t be undone."); + const fetchesBeforeConfirm = customFieldsApi.requests.length; await confirmation.getByRole("button", {name: "Delete"}).click(); await expect.element(settingsScreen.successToast()).toHaveTextContent("Custom field deleted"); expect(deleteApi.requests).toHaveLength(1); + + // The list refetches after the delete; the refreshed outcome (the + // field leaving Archived) is server behavior, owned by the API suite. + await expect.poll(() => customFieldsApi.requests.length).toBeGreaterThan(fetchesBeforeConfirm); }); it("does not expose permanent deletion for an active field", async () => { @@ -280,7 +301,7 @@ describe("Custom fields", () => { it("reactivates an archived field after confirmation, as a status edit", async () => { fakeSettingsScreens(); - fakeCustomFields([companyField, archivedField]); + const customFieldsApi = fakeCustomFields([companyField, archivedField]); const editApi = fakeAdminEndpoint("PUT", "/members/custom_fields/old-hobby/", { members_custom_fields: [{...archivedField, status: "active"}], }); @@ -291,9 +312,14 @@ describe("Custom fields", () => { await settingsScreen.customFieldModal().getByRole("button", {name: "Reactivate"}).click(); const confirmation = settingsScreen.confirmationModal(); await expect.element(confirmation).toHaveTextContent("Values already collected for this field will remain unchanged"); + const fetchesBeforeConfirm = customFieldsApi.requests.length; await confirmation.getByRole("button", {name: "Reactivate"}).click(); await expect.element(settingsScreen.successToast()).toHaveTextContent("Custom field reactivated"); expect(editApi.lastRequest?.body).toEqual({members_custom_fields: [{status: "active"}]}); + + // The list refetches after the edit; the refreshed outcome (the field + // moving back under Active) is server behavior, owned by the API suite. + await expect.poll(() => customFieldsApi.requests.length).toBeGreaterThan(fetchesBeforeConfirm); }); }); diff --git a/apps/admin/src/settings/membership/member-welcome-emails.acceptance.test.tsx b/apps/admin/src/settings/membership/member-welcome-emails.acceptance.test.tsx index 63da3dc5586..9df849573d2 100644 --- a/apps/admin/src/settings/membership/member-welcome-emails.acceptance.test.tsx +++ b/apps/admin/src/settings/membership/member-welcome-emails.acceptance.test.tsx @@ -488,12 +488,13 @@ describe("Member welcome emails", () => { expect(editApi.lastRequest?.body).toMatchObject({automated_emails: [{id: freeWelcomeEmail.id, status: "inactive"}]}); }); - it("shows the paid welcome email row when Stripe is connected", async () => { + it("shows and enables the paid welcome email row when Stripe is connected", async () => { fakeSettingsScreens(); fakeDefaultNewsletter(); fakeAutomatedEmails(); fakeRecentPosts(); fakeTiers([tier({name: "Supporter"})]); + const addApi = fakeAdminEndpoint("POST", "/automated_emails/", {automated_emails: [{...paidWelcomeEmail, status: "active"}]}); const stripe = settingsResponse({settings: { stripe_connect_publishable_key: "pk_test_123", stripe_connect_secret_key: "sk_test_123", @@ -506,6 +507,11 @@ describe("Member welcome emails", () => { await expect.element(paidRow).toBeVisible(); await expect.element(paidRow).toHaveTextContent("Paid members welcome email"); await expect.element(paidRow.getByRole("switch")).toHaveAttribute("aria-checked", "false"); + + await paidRow.getByRole("switch").click(); + + await expect.element(page.getByText("Paid members welcome email enabled")).toBeVisible(); + expect(addApi.lastRequest?.body).toMatchObject({automated_emails: [{slug: "member-welcome-email-paid", status: "active"}]}); }); it("keeps the newest draft's preview when a stale preview response arrives late", async () => { diff --git a/docker/dev-gateway/Dockerfile b/docker/dev-gateway/Dockerfile index 2433e43b77f..490e03839a5 100644 --- a/docker/dev-gateway/Dockerfile +++ b/docker/dev-gateway/Dockerfile @@ -6,16 +6,14 @@ FROM caddy:2-alpine@sha256:5f5c8640aae01df9654968d946d8f1a56c497f1dd5c5cda4cf95a COPY --from=builder /usr/bin/caddy /usr/bin/caddy -# Default proxy targets (can be overridden via environment variables) +# Default proxy targets (can be overridden via environment variables). +# Public apps (portal, comments-ui, signup-form, sodo-search, +# announcement-bar, admin-toolbar) are served directly from their umd/ +# build output via the Caddyfile's file_server block, not proxied to a +# dev server — they have no *_DEV_SERVER entry here. ENV GHOST_BACKEND=ghost-dev:2368 \ ADMIN_DEV_SERVER=host.docker.internal:5174 \ ADMIN_LIVE_RELOAD_SERVER=host.docker.internal:4200 \ - PORTAL_DEV_SERVER=host.docker.internal:4175 \ - COMMENTS_DEV_SERVER=host.docker.internal:7173 \ - SIGNUP_DEV_SERVER=host.docker.internal:6174 \ - SEARCH_DEV_SERVER=host.docker.internal:4178 \ - ANNOUNCEMENT_DEV_SERVER=host.docker.internal:4177 \ - ADMIN_TOOLBAR_DEV_SERVER=host.docker.internal:4176 \ LEXICAL_DEV_SERVER=host.docker.internal:4173 \ ANALYTICS_PROXY_TARGET=analytics:3000 \ ACTIVITYPUB_PROXY_TARGET=host.docker.internal:8080 diff --git a/docker/dev-gateway/README.md b/docker/dev-gateway/README.md index 7a69c6706a6..6f6ba2c4abe 100644 --- a/docker/dev-gateway/README.md +++ b/docker/dev-gateway/README.md @@ -4,8 +4,9 @@ This directory contains the Caddy reverse proxy configuration for the Ghost deve ## Purpose The Caddy reverse proxy container: 1. **Routes Ghost requests** to the Ghost container backend -2. **Proxies asset requests** to local dev servers running on the host -3. **Enables hot-reload** for frontend development without rebuilding Ghost +2. **Proxies asset requests** to local dev servers running on the host (Admin, Lexical) +3. **Serves public app assets** (Portal, Comments UI, Signup Form, Sodo Search, Announcement Bar, Admin Toolbar) directly from their `umd/` build output via `file_server` — no dev server involved +4. **Enables hot-reload** for frontend development without rebuilding Ghost ## Configuration ### Environment Variables @@ -14,11 +15,6 @@ Caddy uses environment variables (set in `compose.dev.yaml`) to configure proxy - `GHOST_BACKEND` - Ghost container hostname (e.g., `ghost-dev:2368`) - `ADMIN_DEV_SERVER` - React admin dev server (e.g., `host.docker.internal:5174`) - `ADMIN_LIVE_RELOAD_SERVER` - Ember live reload WebSocket (e.g., `host.docker.internal:4200`) -- `PORTAL_DEV_SERVER` - Portal dev server (e.g., `host.docker.internal:4175`) -- `COMMENTS_DEV_SERVER` - Comments UI (e.g., `host.docker.internal:7173`) -- `SIGNUP_DEV_SERVER` - Signup form (e.g., `host.docker.internal:6174`) -- `SEARCH_DEV_SERVER` - Sodo search (e.g., `host.docker.internal:4178`) -- `ANNOUNCEMENT_DEV_SERVER` - Announcement bar (e.g., `host.docker.internal:4177`) - `LEXICAL_DEV_SERVER` - *Optional:* Koenig Lexical editor preview server (e.g., `host.docker.internal:4173`) - Started by `pnpm dev:lexical` at the repo root, which runs `koenig/koenig-lexical`'s `dev:integrated` target (editor rebuild watcher + `vite preview` on port 4173) and sets Admin's `EDITOR_URL` to point at it - Automatically falls back to Ghost backend (built package) if dev server is not running @@ -42,15 +38,11 @@ The Caddyfile defines these routing rules: | `/.well-known/webfinger` | ActivityPub server (port 8080) | *Optional:* WebFinger for federation | | `/.well-known/nodeinfo` | ActivityPub server (port 8080) | *Optional:* NodeInfo for federation | | `/ghost/assets/koenig-lexical/*` | Lexical dev server (port 4173) | *Optional:* Koenig editor via `pnpm dev:lexical` (falls back to Ghost) | -| `/ghost/assets/portal/*` | Portal dev server (port 4175) | Membership UI | -| `/ghost/assets/comments-ui/*` | Comments dev server (port 7173) | Comments widget | -| `/ghost/assets/signup-form/*` | Signup dev server (port 6174) | Signup form widget | -| `/ghost/assets/sodo-search/*` | Search dev server (port 4178) | Search widget (JS + CSS) | -| `/ghost/assets/announcement-bar/*` | Announcement dev server (port 4177) | Announcement widget | +| `/ghost/assets/{portal,comments-ui,signup-form,sodo-search,announcement-bar,admin-toolbar}/*` | `apps//umd` (file_server) | Public app widgets — served from disk, rebuilt on change by each app's `vite build --watch` | | `/ghost/assets/*` | Admin dev server (port 5174) | Other admin assets — rewritten to `/__admin-dev__/assets/*` | | `/__admin-dev__/*` | Admin dev server (port 5174) | Vite internals (HMR, modules, refresh runtime, dev-only assets) | | `/ghost`, `/ghost/` | Admin dev server (port 5174) | Admin HTML entry — rewritten to `/__admin-dev__/` | | `/ghost/*` (deep links) | Ghost backend | Express middleware redirects deep links to `/ghost/#/` | | Everything else | Ghost backend | Main Ghost application | -**Note:** All port numbers listed are the host ports where dev servers run by default. +**Note:** Port numbers listed for Admin and Lexical are the host ports where those dev servers run by default. Public apps have no dev-server port — Caddy reads their build output straight off disk. diff --git a/docker/ghost-dev/Dockerfile b/docker/ghost-dev/Dockerfile index d0bf23ae75e..8d4d1fff3a3 100644 --- a/docker/ghost-dev/Dockerfile +++ b/docker/ghost-dev/Dockerfile @@ -6,13 +6,21 @@ ARG NODE_VERSION=22.23.1 FROM node:$NODE_VERSION-bullseye-slim -# Install system dependencies needed for building native modules +# Install system dependencies needed for building native modules. +# watchman: ember-cli (ghost/admin's dev server) auto-detects and prefers it +# over its default Node-based file watcher, which is far less efficient over +# a large tree like this bind-mounted monorepo -- without it, ember serve's +# idle watch mode pegs multiple CPU cores continuously (observed: ~240% CPU +# at idle, not during an active build). Only benefits ghost-dev's own +# container; the host-hybrid flow runs ember-cli on the developer's own +# machine, not in this image, so this has no effect there either way. RUN apt-get update && \ apt-get install -y \ build-essential \ curl \ python3 \ - git && \ + git \ + watchman && \ rm -rf /var/lib/apt/lists/* && \ apt clean diff --git a/e2e/helpers/pages/admin/settings/sections/custom-fields-section.ts b/e2e/helpers/pages/admin/settings/sections/custom-fields-section.ts index 8fbde3b59b3..a251f53724f 100644 --- a/e2e/helpers/pages/admin/settings/sections/custom-fields-section.ts +++ b/e2e/helpers/pages/admin/settings/sections/custom-fields-section.ts @@ -23,14 +23,6 @@ export class CustomFieldsSection extends BasePage { return this.section.getByTestId('custom-field-list-item').filter({hasText: name}); } - tab(name: 'Active' | 'Archived'): Locator { - return this.section.getByRole('tab', {name}); - } - - async openTab(name: 'Active' | 'Archived'): Promise { - await this.tab(name).click(); - } - /** * Create a field of the default (short text) type. That keeps the member * detail editor a plain text input, which is all the cross-surface flow @@ -43,53 +35,4 @@ export class CustomFieldsSection extends BasePage { await this.modal.getByRole('button', {name: 'Save'}).click(); await this.listItem(name).waitFor(); } - - /** Open a field's edit modal from whichever tab it lives in. */ - async openField(name: string): Promise { - await this.listItem(name).click(); - await this.modal.waitFor(); - } - - /** Rename a field in place; the modal closes on success. */ - async renameField(name: string, newName: string): Promise { - await this.openField(name); - await this.modal.getByLabel('Name').fill(newName); - await this.modal.getByRole('button', {name: 'Save'}).click(); - await this.listItem(newName).waitFor(); - } - - /** - * Archive an active field. Both the edit modal and the confirmation dialog - * carry an "Archive" button; the edit modal closes as the confirmation - * opens, so the second click lands on the confirmation. - */ - async archiveField(name: string): Promise { - await this.openField(name); - await this.modal.getByRole('button', {name: 'Archive'}).click(); - // The edit modal removes itself as the confirmation opens; wait for it to - // go so the confirmation's "Archive" button is the only match. - await this.modal.waitFor({state: 'detached'}); - await this.page.getByRole('button', {name: 'Archive'}).click(); - } - - /** Reactivate an archived field (open it from the Archived tab first). */ - async reactivateField(name: string): Promise { - await this.openField(name); - await this.modal.getByRole('button', {name: 'Reactivate'}).click(); - await this.modal.waitFor({state: 'detached'}); - await this.page.getByRole('button', {name: 'Reactivate'}).click(); - } - - /** - * Permanently delete an archived field. Deletion lives behind the modal's - * header menu, then a destructive confirmation — the API only allows it on - * an already-archived field. - */ - async deleteField(name: string): Promise { - await this.openField(name); - await this.modal.getByRole('button', {name: 'Menu'}).click(); - await this.page.getByRole('menuitem', {name: 'Delete custom field'}).click(); - await this.modal.waitFor({state: 'detached'}); - await this.page.getByRole('button', {name: 'Delete', exact: true}).click(); - } } diff --git a/e2e/helpers/pages/admin/settings/sections/index.ts b/e2e/helpers/pages/admin/settings/sections/index.ts index 4008ff03f8d..6adc78f50bc 100644 --- a/e2e/helpers/pages/admin/settings/sections/index.ts +++ b/e2e/helpers/pages/admin/settings/sections/index.ts @@ -2,7 +2,6 @@ export {AnnouncementBarSection} from './announcement-bar-section'; export {DangerZoneSection} from './danger-zone-section'; export {AccessSection} from './access-section'; export {CustomFieldsSection} from './custom-fields-section'; -export {LabsSection} from './labs-section'; export {IntegrationsSection} from './integrations-section'; export {DesignSection} from './design-section'; export {MemberWelcomeEmailsSection} from './member-welcome-emails-section'; diff --git a/e2e/helpers/pages/admin/settings/sections/labs-section.ts b/e2e/helpers/pages/admin/settings/sections/labs-section.ts deleted file mode 100644 index ae5e7cec20a..00000000000 --- a/e2e/helpers/pages/admin/settings/sections/labs-section.ts +++ /dev/null @@ -1,48 +0,0 @@ -import {BasePage} from '@/helpers/pages'; -import {Locator, Page} from '@playwright/test'; - -export class LabsSection extends BasePage { - readonly section: Locator; - readonly heading: Locator; - - readonly openButton: Locator; - readonly closeButton: Locator; - readonly content: Locator; - - readonly betaFeaturesTab: Locator; - readonly privateFeaturesTab: Locator; - - constructor(page: Page) { - super(page, '/ghost/#/settings/labs'); - - this.section = page.getByTestId('labs'); - this.heading = page.getByRole('heading', {level: 5, name: 'Labs'}); - this.content = this.section.locator('[role="tabpanel"]'); - - this.openButton = page.getByTestId('labs').getByRole('button', {name: 'Open'}); - this.closeButton = page.getByTestId('labs').getByRole('button', {name: 'Close'}); - - this.betaFeaturesTab = page.getByRole('tab', {name: 'Beta features'}); - this.privateFeaturesTab = page.getByRole('tab', {name: 'Private features'}); - } - - async isLabsOpen(): Promise { - const closeButtonVisible = await this.closeButton.isVisible().catch(() => false); - const contentVisible = await this.content.isVisible().catch(() => false); - return closeButtonVisible || contentVisible; - } - - async openLabs() { - if (!await this.isLabsOpen()) { - await this.openButton.click(); - await this.content.waitFor({state: 'visible'}); - } - } - - async closeLabs() { - if (await this.isLabsOpen()) { - await this.closeButton.click(); - await this.content.waitFor({state: 'hidden'}); - } - } -} diff --git a/e2e/helpers/pages/admin/settings/sections/member-welcome-emails-section.ts b/e2e/helpers/pages/admin/settings/sections/member-welcome-emails-section.ts index 49e6a24e25b..310ed8447aa 100644 --- a/e2e/helpers/pages/admin/settings/sections/member-welcome-emails-section.ts +++ b/e2e/helpers/pages/admin/settings/sections/member-welcome-emails-section.ts @@ -4,9 +4,7 @@ import {FrameLocator, Locator, Page} from '@playwright/test'; export class MemberWelcomeEmailsSection extends BasePage { readonly section: Locator; readonly freeWelcomeEmailToggle: Locator; - readonly paidWelcomeEmailToggle: Locator; readonly freeWelcomeEmailEditButton: Locator; - readonly paidWelcomeEmailEditButton: Locator; // Customize button and modal readonly customizeButton: Locator; @@ -51,9 +49,7 @@ export class MemberWelcomeEmailsSection extends BasePage { super(page, '/ghost/#/settings/memberemails'); this.section = page.getByTestId('memberemails'); this.freeWelcomeEmailToggle = this.section.getByTestId('free-welcome-email-row').getByRole('switch'); - this.paidWelcomeEmailToggle = this.section.getByTestId('paid-welcome-email-row').getByRole('switch'); this.freeWelcomeEmailEditButton = this.section.getByTestId('free-welcome-email-row').getByRole('button', {name: 'Edit'}); - this.paidWelcomeEmailEditButton = this.section.getByTestId('paid-welcome-email-row').getByRole('button', {name: 'Edit'}); // Customize button and modal this.customizeButton = this.section.getByRole('button', {name: 'Customize'}); @@ -109,48 +105,20 @@ export class MemberWelcomeEmailsSection extends BasePage { } } - async enablePaidWelcomeEmail(): Promise { - if (!await this.isPaidWelcomeEmailEnabled()) { - await this.paidWelcomeEmailToggle.click(); - await this.waitForPaidToggle(true); - } - } - - async disablePaidWelcomeEmail(): Promise { - if (await this.isPaidWelcomeEmailEnabled()) { - await this.paidWelcomeEmailToggle.click(); - await this.waitForPaidToggle(false); - } - } - async isFreeWelcomeEmailEnabled(): Promise { const ariaChecked = await this.freeWelcomeEmailToggle.getAttribute('aria-checked'); return ariaChecked === 'true'; } - async isPaidWelcomeEmailEnabled(): Promise { - const ariaChecked = await this.paidWelcomeEmailToggle.getAttribute('aria-checked'); - return ariaChecked === 'true'; - } - private async waitForFreeToggle(checked: boolean): Promise { const toggle = this.section.getByTestId('free-welcome-email-row').getByRole('switch', {checked}); await toggle.waitFor({state: 'visible'}); } - private async waitForPaidToggle(checked: boolean): Promise { - const toggle = this.section.getByTestId('paid-welcome-email-row').getByRole('switch', {checked}); - await toggle.waitFor({state: 'visible'}); - } - async openFreeWelcomeEmailModal(): Promise { await this.openWelcomeEmailModal(this.freeWelcomeEmailEditButton); } - async openPaidWelcomeEmailModal(): Promise { - await this.openWelcomeEmailModal(this.paidWelcomeEmailEditButton); - } - async saveWelcomeEmail(): Promise { await this.modalSaveButton.click(); await this.modalSavedButton.waitFor({state: 'visible'}); diff --git a/e2e/helpers/pages/admin/settings/settings-page.ts b/e2e/helpers/pages/admin/settings/settings-page.ts index a0e0a0643b5..36a75bb6cbc 100644 --- a/e2e/helpers/pages/admin/settings/settings-page.ts +++ b/e2e/helpers/pages/admin/settings/settings-page.ts @@ -1,36 +1,24 @@ import {BasePage} from '@/helpers/pages'; -import {CustomFieldsSection, DangerZoneSection, IntegrationsSection, LabsSection, PortalSection, TiersSection} from './sections'; +import {CustomFieldsSection, DangerZoneSection, IntegrationsSection, PortalSection, TiersSection} from './sections'; import {Locator, Page} from '@playwright/test'; import {StaffSection} from './sections/staff-section'; export class SettingsPage extends BasePage { - readonly searchInput: Locator; - readonly searchClearButton: Locator; - readonly integrationsSection: IntegrationsSection; readonly portalSection: PortalSection; - readonly labsSection: LabsSection; readonly staffSection: StaffSection; readonly tiersSection: TiersSection; readonly customFieldsSection: CustomFieldsSection; readonly dangerZoneSection: DangerZoneSection; readonly sidebar: Locator; - readonly labsSidebarLink: Locator; - readonly staffSidebarLink: Locator; constructor(page: Page) { super(page, '/ghost/#/settings'); this.sidebar = page.getByTestId('sidebar'); - this.labsSidebarLink = this.sidebar.getByText('Labs'); - this.staffSidebarLink = this.sidebar.getByText('Staff'); - - this.searchInput = page.locator('input[placeholder="Search settings"]'); - this.searchClearButton = page.locator('button[aria-label="close"]').first(); this.portalSection = new PortalSection(page); - this.labsSection = new LabsSection(page); this.integrationsSection = new IntegrationsSection(page); this.staffSection = new StaffSection(page); this.tiersSection = new TiersSection(page); @@ -38,10 +26,6 @@ export class SettingsPage extends BasePage { this.dangerZoneSection = new DangerZoneSection(page); } - async searchByInput(text: string) { - await this.searchInput.fill(text); - } - async goto() { const result = await super.goto(); await this.sidebar.waitFor({state: 'visible'}); diff --git a/e2e/helpers/pages/admin/sidebar/contributor-user-menu.ts b/e2e/helpers/pages/admin/sidebar/contributor-user-menu.ts new file mode 100644 index 00000000000..7faaf94fb79 --- /dev/null +++ b/e2e/helpers/pages/admin/sidebar/contributor-user-menu.ts @@ -0,0 +1,26 @@ +import * as sidebarSel from '@tryghost/test-data/selectors/sidebar'; +import {AdminPage} from '@/admin-pages'; +import {Locator, Page} from '@playwright/test'; + +/** + * Contributors get a floating avatar menu instead of the admin sidebar + * (apps/admin/src/layout/admin-layout.tsx). + */ +export class ContributorUserMenu extends AdminPage { + public readonly trigger: Locator; + public readonly postsMenuItem: Locator; + public readonly viewSiteMenuItem: Locator; + public readonly profileMenuItem: Locator; + + constructor(page: Page) { + super(page); + this.trigger = page.getByRole('button', {name: sidebarSel.contributorMenuTrigger}); + this.postsMenuItem = page.getByRole('menuitem', {name: sidebarSel.contributorPostsMenuItem}); + this.viewSiteMenuItem = page.getByRole('menuitem', {name: sidebarSel.contributorViewSiteMenuItem}); + this.profileMenuItem = page.getByRole('menuitem', {name: sidebarSel.profileMenuItem}); + } + + async open(): Promise { + await this.trigger.click(); + } +} diff --git a/e2e/helpers/pages/admin/sidebar/index.ts b/e2e/helpers/pages/admin/sidebar/index.ts index d96ef6afe35..ca9558e1703 100644 --- a/e2e/helpers/pages/admin/sidebar/index.ts +++ b/e2e/helpers/pages/admin/sidebar/index.ts @@ -1 +1,2 @@ +export * from './contributor-user-menu'; export * from './sidebar-page'; diff --git a/e2e/helpers/pages/admin/sidebar/sidebar-page.ts b/e2e/helpers/pages/admin/sidebar/sidebar-page.ts index 6d1fb51f871..226c9c0d9be 100644 --- a/e2e/helpers/pages/admin/sidebar/sidebar-page.ts +++ b/e2e/helpers/pages/admin/sidebar/sidebar-page.ts @@ -3,7 +3,7 @@ import {AdminPage} from '@/admin-pages'; import {Locator, Page} from '@playwright/test'; import {whatsNewMenuItem} from '@tryghost/test-data/selectors/whats-new'; -export type UserRole = 'Administrator' | 'Editor' | 'Author' | 'Contributor'; +export type UserRole = 'Administrator' | 'Editor' | 'Super Editor' | 'Author' | 'Contributor'; export interface NavItem { name: string; @@ -15,15 +15,20 @@ export interface NavItem { /** * Navigation items in the sidebar with their expected paths and role visibility. * Used for navigation tests and force upgrade redirect validation. + * + * `roles` = roles whose sidebar shows the item (Owner sees everything + * Administrator does; Contributors have no sidebar at all — they get a + * floating user menu instead). Gating source: + * apps/admin/src/layout/app-sidebar/{nav-main,nav-content}.tsx. */ export const NAV_ITEMS: NavItem[] = [ {name: 'Analytics', path: /\/ghost\/#\/analytics\/?$/, directUrl: '/ghost/#/analytics', roles: ['Administrator']}, {name: 'Network', path: /\/ghost\/#\/(network|activitypub)\/?/, directUrl: '/ghost/#/activitypub', roles: ['Administrator']}, - {name: 'View site', path: /\/ghost\/#\/site\/?$/, directUrl: '/ghost/#/site', roles: ['Administrator', 'Editor']}, - {name: 'Posts', path: /\/ghost\/#\/posts\/?$/, directUrl: '/ghost/#/posts', roles: ['Administrator', 'Editor', 'Author', 'Contributor']}, - {name: 'Pages', path: /\/ghost\/#\/pages\/?$/, directUrl: '/ghost/#/pages', roles: ['Administrator', 'Editor']}, - {name: 'Tags', path: /\/ghost\/#\/tags\/?$/, directUrl: '/ghost/#/tags', roles: ['Administrator', 'Editor']}, - {name: 'Members', path: /\/ghost\/#\/members\/?$/, directUrl: '/ghost/#/members', roles: ['Administrator', 'Editor']} + {name: 'View site', path: /\/ghost\/#\/site\/?$/, directUrl: '/ghost/#/site', roles: ['Administrator']}, + {name: 'Posts', path: /\/ghost\/#\/posts\/?$/, directUrl: '/ghost/#/posts', roles: ['Administrator', 'Editor', 'Super Editor', 'Author']}, + {name: 'Pages', path: /\/ghost\/#\/pages\/?$/, directUrl: '/ghost/#/pages', roles: ['Administrator', 'Editor', 'Super Editor', 'Author']}, + {name: 'Tags', path: /\/ghost\/#\/tags\/?$/, directUrl: '/ghost/#/tags', roles: ['Administrator', 'Editor', 'Super Editor']}, + {name: 'Members', path: /\/ghost\/#\/members\/?$/, directUrl: '/ghost/#/members', roles: ['Administrator', 'Super Editor']} ]; /** @@ -36,6 +41,7 @@ export const NAV_ITEMS: NavItem[] = [ */ export class SidebarPage extends AdminPage { public readonly sidebar: Locator; + public readonly adminSidebar: Locator; public readonly postsToggle: Locator; public readonly userDropdownTrigger: Locator; public readonly appearanceMenuItem: Locator; @@ -57,6 +63,9 @@ export class SidebarPage extends AdminPage { // carry a breadcrumb