Skip to content
Open
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
17 changes: 14 additions & 3 deletions .hazelnut/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,9 +61,16 @@ docker build --platform linux/amd64 -t canvas-env:local image
```

The image stores the Drupal environment at `$HAZELNUT_WORKSPACE_DIR/drupal`. At
runtime, `canvas-env-init` links the Canvas checkout from
runtime, `canvas-env-init` moves the Canvas checkout Hazelnut cloned to
`$HAZELNUT_WORKSPACE_DIR/$HAZELNUT_PROJECT_NAME` into Drupal's
`web/modules/contrib/canvas` path.
`web/modules/contrib/canvas` path and leaves a symlink at the workspace path.
The checkout must physically live inside the Drupal tree because Canvas
tooling locates Drupal by walking up parent directories. `canvas-env-start` also runs a virtual
display with a VNC bridge; headed Cypress and Playwright sessions can be watched
at `http://localhost:6080/vnc.html`. The Cypress binary and the Playwright
Chromium browser are baked into the image; their versions are pinned as build
arguments in the Dockerfile and must be kept in sync with what the Canvas
repository resolves.

To test with a local Canvas checkout:

Expand All @@ -73,8 +80,9 @@ docker run -d \
--platform linux/amd64 \
-p 8080:8080 \
-p 5173:5173 \
-p 6080:6080 \
-e HAZELNUT_PROJECT_NAME=canvas \
-v "/path/to/canvas:/home/hazelnut/workspace/canvas" \
-v "/path/to/canvas:/home/hazelnut/workspace/drupal/web/modules/contrib/canvas" \
canvas-env:local \
canvas-env-start sleep infinity

Expand All @@ -83,6 +91,9 @@ docker exec canvas-env-test site-install --stark
docker exec -d canvas-env-test canvas-env-start
```

Mount a clean clone rather than a working checkout: `node_modules` installed on
the host contain platform-specific binaries that fail inside the container.

Open `http://localhost:8080` and sign in with `admin` as both the username and
password. Remove the container when finished:

Expand Down
68 changes: 68 additions & 0 deletions .hazelnut/image/AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
# Drupal Canvas development container

Drupal 11 environment for developing the Drupal Canvas module. PHP 8.3, Node.js
24, Composer 2, MariaDB (local socket, database `db`, user `db`, password `db`).

## Layout

- Canvas checkout (work here): `~/workspace/canvas`
- Drupal environment root: `~/workspace/drupal` (also `$CANVAS_ENV_ROOT`)
- The checkout is symlinked to `web/modules/contrib/canvas`; never edit files
through the Drupal path.

## Setup and serving

Run once per session, from `$CANVAS_ENV_ROOT` (the default directory):

1. `composer install --no-interaction`
2. `site-install` (alias `si`) — installs Drupal plus Canvas.
- default: sample content, builds the UI (first build is slow)
- `--stark`: minimal theme content
- `--ui`: skips the UI build and enables `canvas_vite`, which serves UI
assets from the Vite dev server; then run `ui` to start it on :5173
- `--mercury`: Mercury theme demo content
3. `canvas-env-start` — serves the site at http://127.0.0.1:8080 (run in the
background). Credentials: `admin` / `admin`.

`canvas-env-start` also starts MariaDB and a virtual display with a VNC bridge.
Watch headed browser sessions at http://localhost:6080/vnc.html.

## Commands

All commands work from any directory.

- `phpunit [path]` — PHPUnit with Drupal core's config; paths are relative to
the Canvas module, for example `phpunit tests/src/Unit`. Functional tests need
`canvas-env-start` running.
- `cypress` (alias `cy`) — Cypress e2e run; `--component` for component tests,
`--spec <spec>` to filter, `--open` for interactive mode via VNC.
- `playwright [args]` — `playwright test` in the Canvas root; all Playwright CLI
arguments pass through.
- `phpcs [path]` / `phpstan [path]` — PHPCBF and PHPStan with Canvas's
configuration.
- `ui` — builds the UI, enables `canvas_vite`, and starts the Vite dev server;
`--skip-build` to skip the build.
- `drush`, `composer` — on PATH; Drush is preconfigured with the site URI.

The Cypress binary and Playwright's Chromium are baked into the image; no
browser installs are needed.

## npm and Turborepo

The Canvas repository is an npm workspaces monorepo built with Turborepo. Run
npm scripts from `~/workspace/canvas` (root `package.json`) or a workspace
directory. Builds cache under `.turbo/`; unchanged rebuilds are near-instant.
All workspaces require Node.js `>=22.19.0 <23 || >=24.5.0`; the image ships
24.x.

## Pitfalls

- The Canvas checkout physically lives at
`~/workspace/drupal/web/modules/contrib/canvas`; `~/workspace/canvas` is a
symlink to it. Keep it that way: Canvas tooling finds Drupal by walking up
parent directories from the physical path.
- After enabling or disabling modules outside `site-install`, run
`drush cache:rebuild` before using the Canvas editor.
- Environment variables for tests (`SIMPLETEST_*`, `DRUPAL_TEST_*`, `BASE_URL`,
`DRUPAL_ROOT_CORE`) are preset; do not override them unless a test documents
otherwise.
57 changes: 44 additions & 13 deletions .hazelnut/image/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,11 @@ USER root

ENV CANVAS_ENV_ROOT=${HAZELNUT_WORKSPACE_DIR}/drupal

# Keep these in sync with the versions the Canvas repository resolves:
# `cypress` in ui/package.json, `@playwright/test` in package.json.
ARG CYPRESS_VERSION=13.17.0
ARG PLAYWRIGHT_VERSION=1.59.1

# Drupal Canvas CI runs PHP 8.3, while Debian Trixie provides PHP 8.4.
RUN install -d -m 0755 /etc/apt/keyrings \
&& curl -fsSL https://packages.sury.org/php/apt.gpg \
Expand All @@ -19,6 +24,7 @@ RUN install -d -m 0755 /etc/apt/keyrings \
&& apt-get update \
&& env DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
build-essential \
fluxbox \
libasound2 \
libgbm1 \
libgtk-3-0 \
Expand All @@ -28,6 +34,7 @@ RUN install -d -m 0755 /etc/apt/keyrings \
libxtst6 \
mariadb-server \
nodejs \
novnc \
patch \
php8.3-cli \
php8.3-curl \
Expand All @@ -37,11 +44,13 @@ RUN install -d -m 0755 /etc/apt/keyrings \
php8.3-mysql \
php8.3-opcache \
php8.3-readline \
php8.3-sqlite3 \
php8.3-xml \
php8.3-zip \
pkg-config \
python3 \
rsync \
x11vnc \
xauth \
xvfb \
zip \
Expand All @@ -57,6 +66,17 @@ RUN EXPECTED_CHECKSUM="$(curl -fsSL https://composer.github.io/installer.sig)" \
&& php /tmp/composer-setup.php --quiet --install-dir=/usr/local/bin --filename=composer \
&& rm /tmp/composer-setup.php

# Bake the Cypress binary and the Playwright browsers into the image so the
# test commands can assume they are available. Both stores are version-keyed,
# so bumping the ARGs above is enough when Canvas upgrades.
ENV CYPRESS_CACHE_FOLDER=/opt/cypress-cache \
PLAYWRIGHT_BROWSERS_PATH=/opt/ms-playwright
RUN npm_config_cache=/tmp/npm-cache npx -y "cypress@${CYPRESS_VERSION}" install \
&& npm_config_cache=/tmp/npm-cache npx -y "playwright@${PLAYWRIGHT_VERSION}" \
install --with-deps chromium \
&& rm -rf /tmp/npm-cache /var/lib/apt/lists/* \
&& chown -R "$USER:$USER" "$CYPRESS_CACHE_FOLDER" "$PLAYWRIGHT_BROWSERS_PATH"

# Keep the local database private to the container and tune PHP for development.
RUN printf '%s\n' \
'[mysqld]' \
Expand All @@ -68,40 +88,51 @@ RUN printf '%s\n' \
>/etc/php/8.3/cli/conf.d/99-canvas-env.ini \
&& install -d -m 0755 "$CANVAS_ENV_ROOT/web/modules/contrib"

# The Drupal environment is baked into the image. Hazelnut clones Canvas into
# the session workspace, which is linked into Composer's expected module path.
# The Drupal environment is baked into the image. At runtime canvas-env-init
# moves the Canvas checkout from the session workspace into Composer's
# expected module path and leaves a symlink at the workspace path.
# composer.json and composer.lock are byte-identical copies of the repository
# root files; scripts/ carries the pre-install-cmd hooks they reference.
COPY composer.json composer.lock ${CANVAS_ENV_ROOT}/
COPY recipes/ ${CANVAS_ENV_ROOT}/recipes/
COPY check-repo.sh ${CANVAS_ENV_ROOT}/scripts/check-repo.sh
COPY scripts/ ${CANVAS_ENV_ROOT}/scripts/
COPY web/sites/default/settings.php ${CANVAS_ENV_ROOT}/web/sites/default/settings.php
COPY bin/ /usr/local/bin/

RUN jq '.scripts["pre-install-cmd"] = ["./scripts/check-repo.sh"]' \
"$CANVAS_ENV_ROOT/composer.json" >/tmp/composer.json \
&& mv /tmp/composer.json "$CANVAS_ENV_ROOT/composer.json" \
&& chown -R "$USER:$USER" "$CANVAS_ENV_ROOT" "$HOME/.config" \
&& chmod 0755 "$CANVAS_ENV_ROOT/scripts/check-repo.sh" \
RUN chown -R "$USER:$USER" "$CANVAS_ENV_ROOT" "$HOME/.config" \
&& chmod 0755 "$CANVAS_ENV_ROOT"/scripts/*.sh \
/usr/local/bin/canvas-env-* /usr/local/bin/cypress \
/usr/local/bin/n /usr/local/bin/phpcs /usr/local/bin/phpstan \
/usr/local/bin/phpcs /usr/local/bin/phpstan \
/usr/local/bin/phpunit /usr/local/bin/playwright /usr/local/bin/site-install \
/usr/local/bin/ui \
&& ln -s cypress /usr/local/bin/cy \
&& ln -s site-install /usr/local/bin/si

USER ${USER}

ENV COMPOSER_MEMORY_LIMIT=-1 \
CYPRESS_CACHE_FOLDER=${CANVAS_ENV_ROOT}/.cache/cypress \
# vendor/bin is appended, not prepended, so the /usr/local/bin wrappers
# (phpunit, phpcs, phpstan) are not shadowed by the Composer-installed
# binaries of the same name once composer install has run.
ENV BASE_URL=http://127.0.0.1:8080 \
COMPOSER_MEMORY_LIMIT=-1 \
DB_URL=mysql://db:db@127.0.0.1:3306/db \
DISPLAY=:0 \
DRUPAL_ROOT_CORE=${CANVAS_ENV_ROOT}/web/core \
DRUPAL_TEST_BASE_URL=http://127.0.0.1:8080 \
DRUPAL_TEST_DB_URL=mysql://db:db@127.0.0.1:3306/db \
DRUPAL_TEST_DRUPAL_ROOT=${CANVAS_ENV_ROOT}/web \
DRUSH_OPTIONS_URI=http://127.0.0.1:8080 \
NODE_OPTIONS=--max-old-space-size=4096 \
PHP_CLI_SERVER_WORKERS=8 \
npm_config_cache=${CANVAS_ENV_ROOT}/.cache/npm \
npm_config_fund=false \
npm_config_update_notifier=false \
SIMPLETEST_BASE_URL=http://127.0.0.1:8080 \
SIMPLETEST_DB=mysql://db:db@127.0.0.1:3306/db \
TURBO_TELEMETRY_DISABLED=1 \
VITE_SERVER_ORIGIN=http://127.0.0.1:5173 \
PATH=${CANVAS_ENV_ROOT}/vendor/bin:${PATH}
PATH=${PATH}:${CANVAS_ENV_ROOT}/vendor/bin

EXPOSE 8080 5173
EXPOSE 8080 5173 6080

WORKDIR ${CANVAS_ENV_ROOT}
39 changes: 32 additions & 7 deletions .hazelnut/image/bin/canvas-env-init
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,40 @@ WORKSPACE_ROOT="${HAZELNUT_WORKSPACE_DIR:-$HOME/workspace}"
ENV_ROOT="${CANVAS_ENV_ROOT:-$WORKSPACE_ROOT/drupal}"
CANVAS_ROOT="${CANVAS_ROOT:-$WORKSPACE_ROOT/${HAZELNUT_PROJECT_NAME:?HAZELNUT_PROJECT_NAME must be set when CANVAS_ROOT is not set}}"
FILES_DIR="$ENV_ROOT/web/sites/default/files"
MODULE_LINK="$ENV_ROOT/web/modules/contrib/canvas"
MODULE_DIR="$ENV_ROOT/web/modules/contrib/canvas"

if [[ -L "$MODULE_LINK" ]]; then
ln -sfn "$CANVAS_ROOT" "$MODULE_LINK"
elif [[ -e "$MODULE_LINK" ]]; then
echo "Cannot link Canvas because $MODULE_LINK already exists and is not a symlink." >&2
exit 1
has_content() {
[[ -d "$1" ]] \
&& [[ -n "$(find "$1/" -mindepth 1 -maxdepth 1 -print -quit 2>/dev/null)" ]]
}

# The checkout must physically live inside the Drupal tree: Canvas tooling
# (its Composer scripts, cypress.config.js, @drupal-canvas/test-utils, and
# @drupal/playwright) locates Drupal by walking up parent directories from
# the physical path. The workspace path stays valid as a symlink.
if [[ ! -L "$MODULE_DIR" ]] && has_content "$MODULE_DIR"; then
# The checkout is already in place, for example bind-mounted there.
if [[ -L "$CANVAS_ROOT" || ! -e "$CANVAS_ROOT" ]]; then
ln -sfn "$MODULE_DIR" "$CANVAS_ROOT"
fi
elif [[ ! -L "$CANVAS_ROOT" ]] && has_content "$CANVAS_ROOT"; then
# Hazelnut cloned the checkout into the workspace. Move it into the
# Drupal tree and leave a symlink behind. A bind-mounted workspace
# checkout cannot be moved; fall back to linking it into the Drupal tree,
# which keeps the environment usable, although parent-walking test
# tooling then only works where an environment override exists.
rm -rf "$MODULE_DIR" 2>/dev/null || true
if mv "$CANVAS_ROOT" "$MODULE_DIR" 2>/dev/null; then
ln -s "$MODULE_DIR" "$CANVAS_ROOT"
else
echo "Warning: cannot move $CANVAS_ROOT into the Drupal tree (is it a mount?); linking instead." >&2
ln -sfn "$CANVAS_ROOT" "$MODULE_DIR"
fi
elif [[ -L "$CANVAS_ROOT" || -L "$MODULE_DIR" ]]; then
: # Already initialized.
else
ln -s "$CANVAS_ROOT" "$MODULE_LINK"
echo "Canvas is not available at $CANVAS_ROOT or $MODULE_DIR." >&2
exit 1
fi

if ! sudo mariadb-admin ping --silent >/dev/null 2>&1; then
Expand Down
21 changes: 21 additions & 0 deletions .hazelnut/image/bin/canvas-env-start
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,27 @@ set -euo pipefail

WORKSPACE_ROOT="${HAZELNUT_WORKSPACE_DIR:-$HOME/workspace}"
ENV_ROOT="${CANVAS_ENV_ROOT:-$WORKSPACE_ROOT/drupal}"
VNC_DISPLAY="${DISPLAY:-:0}"

canvas-env-init

# Run a virtual display with a VNC bridge so headed Cypress and Playwright
# sessions can be watched at http://localhost:6080/vnc.html via noVNC.
if ! pgrep -x Xvfb >/dev/null 2>&1; then
Xvfb "$VNC_DISPLAY" -screen 0 1920x1080x24 >/dev/null 2>&1 &
sleep 1
fi
if ! pgrep -x fluxbox >/dev/null 2>&1; then
fluxbox -display "$VNC_DISPLAY" >/dev/null 2>&1 &
fi
if ! pgrep -x x11vnc >/dev/null 2>&1; then
x11vnc -display "$VNC_DISPLAY" -forever -shared -nopw -quiet -bg >/dev/null 2>&1
fi
if ! pgrep -f novnc_proxy >/dev/null 2>&1; then
/usr/share/novnc/utils/novnc_proxy --web /usr/share/novnc \
--vnc localhost:5900 --listen 6080 >/dev/null 2>&1 &
fi

if (( $# > 0 )); then
exec "$@"
fi
Expand All @@ -16,6 +34,9 @@ if [[ ! -f "$ENV_ROOT/web/.ht.router.php" ]]; then
exit 1
fi

# PHP's built-in server does not change its working directory, and some code
# includes files relative to the docroot, so it must run from there.
cd "$ENV_ROOT/web"
exec php -S "0.0.0.0:${CANVAS_HTTP_PORT:-8080}" \
-t "$ENV_ROOT/web" \
"$ENV_ROOT/web/.ht.router.php"
26 changes: 21 additions & 5 deletions .hazelnut/image/bin/cypress
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ WORKSPACE_ROOT="${HAZELNUT_WORKSPACE_DIR:-$HOME/workspace}"
CANVAS_ROOT="${CANVAS_ROOT:-$WORKSPACE_ROOT/${HAZELNUT_PROJECT_NAME:?HAZELNUT_PROJECT_NAME must be set when CANVAS_ROOT is not set}}"
UI_ROOT="$CANVAS_ROOT/ui"
COMPONENT=false
OPEN=false
SPEC=""

while (( $# > 0 )); do
Expand All @@ -14,27 +15,37 @@ while (( $# > 0 )); do
COMPONENT=true
shift
;;
--open|-o)
OPEN=true
shift
;;
--spec)
[[ $# -ge 2 ]] || { echo "--spec requires a value" >&2; exit 1; }
SPEC="$2"
shift 2
;;
*)
echo "Unknown option: $1" >&2
echo "Usage: cypress [--component|-c] [--spec <spec>]" >&2
echo "Usage: cypress [--component|-c] [--open|-o] [--spec <spec>]" >&2
exit 1
;;
esac
done

cd "$UI_ROOT"

if [[ ! -x node_modules/.bin/cypress ]]; then
echo "Cypress is not installed. Run: n install" >&2
# npm workspaces hoist dependencies to the repository root node_modules.
if [[ ! -x "$CANVAS_ROOT/node_modules/.bin/cypress" ]]; then
echo "Node dependencies are not installed. Run: npm install --prefix $CANVAS_ROOT" >&2
exit 1
fi
if ! npm run cy:verify >/dev/null 2>&1; then
npx cypress install

# Dismiss the Cypress welcome message so open mode starts on the project.
STATE_DIR="$HOME/.config/Cypress/cy/production/projects/__global__"
if [[ ! -f "$STATE_DIR/state.json" ]]; then
mkdir -p "$STATE_DIR"
printf '{"majorVersionWelcomeDismissed": {"13": %s}}\n' "$(date +%s)" \
>"$STATE_DIR/state.json"
fi

ARGS=(--browser electron)
Expand All @@ -47,4 +58,9 @@ if [[ -n "$SPEC" ]]; then
ARGS+=(--spec "$SPEC")
fi

if [[ "$OPEN" == true ]]; then
echo "Watch the Cypress session at http://localhost:6080/vnc.html"
exec npm run cy:open -- "${ARGS[@]}"
fi

exec npm run cy:run -- "${ARGS[@]}"
Loading