diff --git a/.dockerignore b/.dockerignore
index cf3913aa0..7e7547f2c 100644
--- a/.dockerignore
+++ b/.dockerignore
@@ -1,3 +1,44 @@
+# Version control
.git
.gitignore
+.gitattributes
+.gitmodules
+.github
+
+# Documentation & metadata
+README.md
+LICENSE.md
+CLAUDE.md
+docs/
+
+# Kubernetes manifests
+k8/
+
+# IDE & OS files
+.idea/
+*.swp
+*.swo
+*~
**/.DS_Store
+
+# Build artifacts & temp files
+*.o
+*.bs
+*.pid
+*.pm.tdy
+logs/*
+tmp/*
+
+# Local data (mounted at runtime, not baked in)
+webwork-open-problem-library/
+private/
+
+# Node modules (npm install runs in Dockerfile)
+node_modules
+public/node_modules
+lib/PG/htdocs/node_modules
+
+# Generated assets (built in Dockerfile)
+public/**/*.min.js
+public/**/*.min.css
+public/static-assets.json
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
new file mode 100644
index 000000000..fa66f1186
--- /dev/null
+++ b/.github/workflows/test.yml
@@ -0,0 +1,69 @@
+name: Integration Tests
+
+on:
+ push:
+ branches: [main, development]
+ pull_request:
+ branches: [main, development]
+
+jobs:
+ test:
+ runs-on: ubuntu-latest
+ timeout-minutes: 20
+
+ steps:
+ - name: Checkout with submodules
+ uses: actions/checkout@v4
+ with:
+ submodules: recursive
+
+ - name: Build Docker image
+ run: docker build -t renderer-test .
+
+ - name: Start container
+ run: |
+ docker run -d \
+ --name renderer-test \
+ -p 3000:3000 \
+ -e MOJO_MODE=development \
+ -v "${{ github.workspace }}/t/ci/fixtures:/usr/app/private/test:ro" \
+ renderer-test \
+ morbo -l 'http://*:3000' ./script/renderer
+
+ - name: Wait for health
+ run: |
+ for i in $(seq 1 30); do
+ if curl -sf --max-time 5 http://localhost:3000/health >/dev/null 2>&1; then
+ echo "Renderer healthy after $((i * 2))s"
+ exit 0
+ fi
+ sleep 2
+ done
+ echo "Renderer failed to start"
+ docker logs renderer-test 2>&1 | tail -50
+ exit 1
+
+ - name: PG unit tests (informational)
+ continue-on-error: true
+ run: |
+ docker exec renderer-test bash -c \
+ 'export PG_ROOT=/usr/app/lib/PG && cd $PG_ROOT && prove -lr \
+ t/macros t/contexts t/math_objects t/pg_problems t/units'
+
+ - name: Smoke tests
+ run: bash t/ci/01-smoke.sh
+
+ - name: Render parity tests
+ run: bash t/ci/02-render-parity.sh
+
+ - name: Answer cycle tests
+ run: bash t/ci/03-answer-cycle.sh
+
+ - name: Endpoint tests
+ run: bash t/ci/04-endpoints.sh
+
+ - name: Cleanup
+ if: always()
+ run: |
+ docker stop renderer-test 2>/dev/null || true
+ docker rm renderer-test 2>/dev/null || true
diff --git a/Dockerfile b/Dockerfile
index 399c0d17d..b1ea9f529 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,4 +1,5 @@
-FROM ubuntu:20.04
+# Stage 1: Builder — install all build tools, compile Perl XS modules, generate JS/CSS assets
+FROM ubuntu:24.04 AS builder
LABEL org.opencontainers.image.source=https://github.com/openwebwork/renderer
WORKDIR /usr/app
@@ -41,12 +42,13 @@ RUN apt-get update \
libdata-structure-util-perl \
liblocale-maketext-lexicon-perl \
libyaml-libyaml-perl \
- && curl -fsSL https://deb.nodesource.com/setup_16.x | bash - \
+ && curl -fsSL https://deb.nodesource.com/setup_22.x | bash - \
&& apt-get install -y --no-install-recommends --no-install-suggests nodejs \
&& apt-get clean \
&& rm -fr /var/lib/apt/lists/* /tmp/*
-RUN cpanm install Mojo::Base Statistics::R::IO::Rserve Date::Format Future::AsyncAwait Crypt::JWT IO::Socket::SSL CGI::Cookie \
+COPY cpanfile .
+RUN cpanm --installdeps . \
&& rm -fr ./cpanm /root/.cpanm /tmp/*
COPY . .
@@ -55,12 +57,61 @@ RUN cp renderer.conf.dist renderer.conf
RUN cp conf/pg_config.yml lib/PG/conf/pg_config.yml
-RUN cd public/ && npm install && cd ..
+# Install all npm deps (including devDependencies for asset generation),
+# then prune to production-only for the runtime image.
+RUN cd public/ && npm install && npm prune --omit=dev && cd ..
-RUN cd lib/PG/htdocs && npm install && cd ../../..
+RUN cd lib/PG/htdocs && npm install && npm prune --omit=dev && cd ../../..
+
+# Stage 2: Runtime — only what's needed to serve requests
+FROM ubuntu:24.04
+
+LABEL org.opencontainers.image.source=https://github.com/openwebwork/renderer
+
+WORKDIR /usr/app
+ARG DEBIAN_FRONTEND=noninteractive
+ENV TZ=America/New_York
+
+RUN apt-get update \
+ && apt-get install -y --no-install-recommends --no-install-suggests \
+ apt-utils \
+ curl \
+ dvipng \
+ openssl \
+ libgd-perl \
+ imagemagick \
+ libdbi-perl \
+ libjson-perl \
+ libcgi-pm-perl \
+ libjson-xs-perl \
+ ca-certificates \
+ libstorable-perl \
+ libdatetime-perl \
+ libuuid-tiny-perl \
+ libtie-ixhash-perl \
+ libhttp-async-perl \
+ libnet-ssleay-perl \
+ libarchive-zip-perl \
+ libcrypt-ssleay-perl \
+ libclass-accessor-perl \
+ libstring-shellquote-perl \
+ libproc-processtable-perl \
+ libmath-random-secure-perl \
+ libdata-structure-util-perl \
+ liblocale-maketext-lexicon-perl \
+ libyaml-libyaml-perl \
+ && apt-get clean \
+ && rm -fr /var/lib/apt/lists/* /tmp/*
+
+# Copy cpanm-installed Perl modules (XS .so + pure Perl) and Mojo binaries.
+# Copies all of /usr/local/ to stay architecture-independent (avoids hardcoding aarch64/x86_64 paths).
+COPY --from=builder /usr/local /usr/local
+
+# Copy the full app tree (includes pruned node_modules and generated assets)
+COPY --from=builder /usr/app /usr/app
EXPOSE 3000
HEALTHCHECK CMD curl -I localhost:3000/health
-CMD hypnotoad -f ./script/renderer
+CMD ["hypnotoad", "-f", "./script/renderer"]
diff --git a/Dockerfile_with_OPL b/Dockerfile_with_OPL
index 847664d55..83ea9de17 100644
--- a/Dockerfile_with_OPL
+++ b/Dockerfile_with_OPL
@@ -1,4 +1,5 @@
-FROM ubuntu:20.04
+# Stage 1: Builder — install all build tools, compile Perl XS modules, generate JS/CSS assets
+FROM ubuntu:24.04 AS builder
LABEL org.opencontainers.image.source=https://github.com/openwebwork/renderer
WORKDIR /usr/app
@@ -41,12 +42,13 @@ RUN apt-get update \
libdata-structure-util-perl \
liblocale-maketext-lexicon-perl \
libyaml-libyaml-perl \
- && curl -fsSL https://deb.nodesource.com/setup_16.x | bash - \
+ && curl -fsSL https://deb.nodesource.com/setup_22.x | bash - \
&& apt-get install -y --no-install-recommends --no-install-suggests nodejs \
&& apt-get clean \
&& rm -fr /var/lib/apt/lists/* /tmp/*
-RUN cpanm install Mojo::Base Statistics::R::IO::Rserve Date::Format Future::AsyncAwait Crypt::JWT IO::Socket::SSL CGI::Cookie \
+COPY cpanfile .
+RUN cpanm --installdeps . \
&& rm -fr ./cpanm /root/.cpanm /tmp/*
ENV MOJO_MODE=production
@@ -66,12 +68,62 @@ RUN cp renderer.conf.dist renderer.conf
RUN cp conf/pg_config.yml lib/PG/conf/pg_config.yml
-RUN npm install
+# Install all npm deps (including devDependencies for asset generation),
+# then prune to production-only for the runtime image.
+RUN cd public/ && npm install && npm prune --omit=dev && cd ..
-RUN cd lib/PG/htdocs && npm install && cd ../../..
+RUN cd lib/PG/htdocs && npm install && npm prune --omit=dev && cd ../../..
+
+# Stage 2: Runtime — only what's needed to serve requests
+FROM ubuntu:24.04
+
+LABEL org.opencontainers.image.source=https://github.com/openwebwork/renderer
+
+WORKDIR /usr/app
+ARG DEBIAN_FRONTEND=noninteractive
+ENV TZ=America/New_York
+ENV MOJO_MODE=production
+
+RUN apt-get update \
+ && apt-get install -y --no-install-recommends --no-install-suggests \
+ apt-utils \
+ curl \
+ dvipng \
+ openssl \
+ libgd-perl \
+ imagemagick \
+ libdbi-perl \
+ libjson-perl \
+ libcgi-pm-perl \
+ libjson-xs-perl \
+ ca-certificates \
+ libstorable-perl \
+ libdatetime-perl \
+ libuuid-tiny-perl \
+ libtie-ixhash-perl \
+ libhttp-async-perl \
+ libnet-ssleay-perl \
+ libarchive-zip-perl \
+ libcrypt-ssleay-perl \
+ libclass-accessor-perl \
+ libstring-shellquote-perl \
+ libproc-processtable-perl \
+ libmath-random-secure-perl \
+ libdata-structure-util-perl \
+ liblocale-maketext-lexicon-perl \
+ libyaml-libyaml-perl \
+ && apt-get clean \
+ && rm -fr /var/lib/apt/lists/* /tmp/*
+
+# Copy cpanm-installed Perl modules (XS .so + pure Perl) and Mojo binaries.
+# Copies all of /usr/local/ to stay architecture-independent (avoids hardcoding aarch64/x86_64 paths).
+COPY --from=builder /usr/local /usr/local
+
+# Copy the full app tree (includes OPL, pruned node_modules, and generated assets)
+COPY --from=builder /usr/app /usr/app
EXPOSE 3000
HEALTHCHECK CMD curl -I localhost:3000/health
-CMD hypnotoad -f ./script/renderer
+CMD ["hypnotoad", "-f", "./script/renderer"]
diff --git a/README.md b/README.md
index 1855586a0..a92fadd0b 100644
--- a/README.md
+++ b/README.md
@@ -5,7 +5,7 @@
This is a PG Renderer derived from the WeBWorK2 codebase
-- [https://github.com/openwebwork/webwork2](https://github.com/openwebwork/webwork2)
+* [https://github.com/openwebwork/webwork2](https://github.com/openwebwork/webwork2)
## DOCKER CONTAINER INSTALL
@@ -33,7 +33,7 @@ If you have non-OPL content, it can be mounted as a volume at `/usr/app/private`
```
A default configuration file is included in the container, but it can be overridden by mounting a replacement at the
-application root. This is necessary if, for example, you want to run the container in `production` mode.
+ application root. This is necessary if, for example, you want to run the container in `production` mode.
```bash
--mount type=bind,source=/pathToYour/renderer.conf,target=/usr/app/renderer.conf \
@@ -43,69 +43,146 @@ application root. This is necessary if, for example, you want to run the contain
If using a local install instead of docker:
-- Clone the renderer and its submodules: `git clone --recursive https://github.com/openwebwork/renderer`
-- Enter the project directory: `cd renderer`
-- Install Perl dependencies listed in Dockerfile (CPANMinus recommended)
-- clone webwork-open-problem-library into the provided stub ./webwork-open-problem-library
- - `git clone https://github.com/openwebwork/webwork-open-problem-library ./webwork-open-problem-library`
-- copy `renderer.conf.dist` to `renderer.conf` and make any desired modifications
-- copy `conf/pg_config.yml` to `lib/PG/pg_config.yml` and make any desired modifications
-- install third party JavaScript dependencies
- - `cd public/`
- - `npm ci`
- - `cd ..`
-- install PG JavaScript dependencies
- - `cd lib/PG/htdocs`
- - `npm ci`
-- start the app with `morbo ./script/renderer` or `morbo -l http://localhost:3000 ./script/renderer` if changing
+* Clone the renderer and its submodules: `git clone --recursive https://github.com/openwebwork/renderer`
+* Enter the project directory: `cd renderer`
+* Install Perl dependencies listed in Dockerfile (CPANMinus recommended)
+* clone webwork-open-problem-library into the provided stub ./webwork-open-problem-library
+ * `git clone https://github.com/openwebwork/webwork-open-problem-library ./webwork-open-problem-library`
+* copy `renderer.conf.dist` to `renderer.conf` and make any desired modifications
+* copy `conf/pg_config.yml` to `lib/PG/pg_config.yml` and make any desired modifications
+* install third party JavaScript dependencies
+ * `cd public/`
+ * `npm ci`
+ * `cd ..`
+* install PG JavaScript dependencies
+ * `cd lib/PG/htdocs`
+ * `npm ci`
+* start the app with `morbo ./script/renderer` or `morbo -l http://localhost:3000 ./script/renderer` if changing
root url
-- access on `localhost:3000` by default or otherwise specified root url
+* access on `localhost:3000` by default or otherwise specified root url
## Editor Interface
-- point your browser at [`localhost:3000`](http://localhost:3000/)
-- select an output format (see below)
-- specify a problem path (e.g. `Library/Rochester/setMAAtutorial/hello.pg`) and a problem seed (e.g. `1234`)
-- click on "Load" to load the problem source into the editor
-- render the contents of the editor (with or without edits) via "Render contents of editor"
-- click on "Save" to save your edits to the specified file path
+* point your browser at [`localhost:3000`](http://localhost:3000/)
+* select an output format (see below)
+* specify a problem path (e.g. `Library/Rochester/setMAAtutorial/hello.pg`) and a problem seed (e.g. `1234`)
+* click on "Load" to load the problem source into the editor
+* render the contents of the editor (with or without edits) via "Render contents of editor"
+* click on "Save" to save your edits to the specified file path

## Server Configuration
-Modification of `baseURL` may be necessary to separate multiple services running on `SITE_HOST`, and will be used to
-extend `SITE_HOST`. The result of this extension will serve as the root URL for accessing the renderer (and any
-supplementary assets it may need to provide in support of a rendered problem). If `baseURL` is an absolute URL, it will
-be used verbatim -- userful if the renderer is running behind a load balancer.
+Configuration lives in `renderer.conf` (copied from `renderer.conf.dist` during build). All key settings can also be overridden via environment variables, which take precedence over the config file — this is the recommended approach for Docker deployments.
-By default, `formURL` will further extend `baseURL`, and serve as the form-data target for user interactions with
-problems rendered by this service. If `formURL` is an absolute URL, it will be used verbatim -- useful if your
-implementation intends to sit in between the user and the renderer.
+### Configuration Reference
+
+| Setting | Env Override | Description |
+|---------|-------------|-------------|
+| `SITE_HOST` | `SITE_HOST` | Public-facing origin URL. Used as `` in rendered HTML and as issuer/audience in JWTs. Must match what the end user's browser sees. |
+| `baseURL` | `baseURL` | Path prefix when mounted at a subpath (e.g. `renderer` for `https://example.com/renderer/`). If set to an absolute URL, overrides `SITE_HOST` for asset references. Leave empty when hosting at root. |
+| `formURL` | `formURL` | Where answer forms POST to. Defaults to `{SITE_HOST}{baseURL}/render-api`. Set to an absolute URL for MITM deployments. |
+| `problemJWTsecret` | `problemJWTsecret` | Shared secret for encrypting render configuration JWTs. Must match any service that creates problem tokens. |
+| `webworkJWTsecret` | `webworkJWTsecret` | Shared secret for session state JWTs (attempt history, scores). |
+| `CORS_ORIGIN` | — | Allowed origin for CORS headers. Set to the embedding site's origin for iframe deployments. `*` is insecure. |
+| `STRICT_JWT` | `STRICT_JWT` | When `1`, rejects requests without a `problemJWT` or `sessionJWT`. Prevents raw-parameter API access. |
+| `FULL_APP_INSECURE` | — | Enables editor UI, OPL browser, and file management routes in production mode. Always available in development mode. |
+| `STATIC_EXPIRES` | — | `Cache-Control` max-age (seconds) for static assets under `/webwork2_files/`. |
+
+### Deployment Topologies
+
+The renderer was designed to support several integration patterns. The URL configuration (`SITE_HOST`, `baseURL`, `formURL`) and JWT architecture adapt to each.
+
+#### Standalone
+
+The renderer serves problems directly to the user's browser. Simplest setup.
+
+```
+ Browser ←→ Renderer
+```
+
+```bash
+docker run -d -p 3000:3000 \
+ -e SITE_HOST=https://renderer.example.com \
+ renderer
+```
+
+`SITE_HOST` is the renderer's own public URL. `baseURL` and `formURL` are empty (defaults). The browser loads rendered HTML and submits answers directly to the renderer.
+
+#### MITM Proxy
+
+A middleware sits between the student and the renderer. The student's browser talks to the proxy, which forwards render requests and intercepts answer submissions.
+
+```
+ Browser ←→ Proxy ←→ Renderer
+```
+
+```bash
+docker run -d -p 3000:3000 \
+ -e SITE_HOST=http://localhost:3000 \
+ -e baseURL=https://proxy.example.com/webwork/ \
+ -e formURL=https://proxy.example.com/webwork/render-api \
+ renderer
+```
+
+- `baseURL` is absolute (the proxy's origin) — rendered HTML references assets through the proxy
+- `formURL` is absolute — answer forms POST to the proxy, not the renderer directly
+- The proxy forwards render requests to the renderer's internal address and relays responses
+
+#### Triangular / Iframe (e.g. LibreTexts)
+
+The LMS and renderer are separate services. The student's browser communicates with both: the LMS issues a JWT, the browser loads the renderer in an iframe using that JWT, and the renderer reports scores back to the LMS asynchronously.
+
+```
+ LMS (LibreTexts)
+ ↗ (1. get JWT) ↖ (3. answerJWT callback)
+ Browser ——————————→ Renderer (iframe)
+ (2. render + submit via JWT)
+```
+
+1. Student requests a problem from the LMS
+2. LMS issues a `problemJWT` containing the render config and a `JWTanswerURL` pointing back at the LMS grading endpoint
+3. Student's browser loads the renderer in an iframe, passing the `problemJWT`
+4. On answer submission, the renderer POSTs an `answerJWT` (containing score + sessionJWT) to the `JWTanswerURL` from inside the token
+5. LMS updates its gradebook; student can resume via `sessionJWT` if the iframe closes
+
+```bash
+docker run -d -p 3000:3000 \
+ -e SITE_HOST=https://renderer.example.com \
+ -e CORS_ORIGIN=https://lms.example.com \
+ -e STRICT_JWT=1 \
+ renderer
+```
+
+- `SITE_HOST` must match the iframe's `src` origin (what the browser sees)
+- `CORS_ORIGIN` is the LMS origin (the iframe's parent)
+- `STRICT_JWT=1` — only JWT-authenticated requests are accepted
+- `problemJWTsecret` must be shared between the LMS and renderer
+- `JWTanswerURL` is embedded in the JWT by the LMS, not configured on the renderer
## Renderer API
-Can be accessed by POST to `{SITE_HOST}{baseURL}{formURL}`.
+Can be accessed by POST to `{SITE_HOST}{baseURL}{formURL}`.
By default, `localhost:3000/render-api`.
### **REQUIRED PARAMETERS**
The bare minimum of parameters that must be included are:
-
-- the code for the problem, so, **ONE** of the following (in order of precedence):
- - `problemSource` (raw pg source code, _can_ be base64 encoded)
- - `sourceFilePath` (relative to OPL `Library/`, `Contrib/`; or in `private/`)
- - `problemSourceURL` (fetch the pg source from remote server)
-- a "seed" value for consistent randomization
- - `problemSeed` (integer)
-
-| Key | Type | Description | Notes |
-| ---------------- | -------------------------------- | ---------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
-| problemSource | string (possibly base64 encoded) | The source code of a problem to be rendered | Takes precedence over `sourceFilePath`. |
-| sourceFilePath | string | The path to the file that contains the problem source code | Renderer will automatically adjust `Library/` and `Contrib/` relative to the webwork-open-problem-library root. Path may also begin with `private/` for local, non-OPL content. |
-| problemSourceURL | string | The URL from which to fetch the problem source code | Takes precedence over `problemSource` and `sourceFilePath`. A request to this URL is expected to return valid pg source code in base64 encoding. |
-| problemSeed | number | The seed that determines the randomization of a problem | |
+* the code for the problem, so, **ONE** of the following (in order of precedence):
+ * `problemSource` (raw pg source code, _can_ be base64 encoded)
+ * `sourceFilePath` (relative to OPL `Library/`, `Contrib/`; or in `private/`)
+ * `problemSourceURL` (fetch the pg source from remote server)
+* a "seed" value for consistent randomization
+ * `problemSeed` (integer)
+
+| Key | Type | Description | Notes |
+| --- | ---- | ----------- | ----- |
+| problemSource | string (possibly base64 encoded) | The source code of a problem to be rendered | Takes precedence over `sourceFilePath`. |
+| sourceFilePath | string | The path to the file that contains the problem source code | Renderer will automatically adjust `Library/` and `Contrib/` relative to the webwork-open-problem-library root. Path may also begin with `private/` for local, non-OPL content. |
+| problemSourceURL | string | The URL from which to fetch the problem source code | Takes precedence over `problemSource` and `sourceFilePath`. A request to this URL is expected to return valid pg source code in base64 encoding. |
+| problemSeed | number | The seed that determines the randomization of a problem | |
**ALL** other request parameters are optional.
@@ -113,10 +190,10 @@ The bare minimum of parameters that must be included are:
The defaults for these parameters are set in `renderer.conf`, but these can be overridden on a per-request basis.
-| Key | Type | Default Value | Description | Notes |
-| ------- | ------ | ----------------------------------------- | --------------------------- | ----- |
-| baseURL | string | '/' (as set in `renderer.conf`) | the URL for relative paths | |
-| formURL | string | '/render-api' (as set in `renderer.conf`) | the URL for form submission | |
+| Key | Type | Default Value | Description | Notes |
+| --- | ---- | ------------- | ----------- | ----- |
+| baseURL | string | '/' (as set in `renderer.conf`) | the URL for relative paths | |
+| formURL | string | '/render-api' (as set in `renderer.conf`) | the URL for form submission | |
### Display Parameters
@@ -124,12 +201,12 @@ The defaults for these parameters are set in `renderer.conf`, but these can be o
Parameters that control the structure and templating of the response.
-| Key | Type | Default Value | Description | Notes |
-| ------------ | ------ | ------------- | ------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- |
-| language | string | en | Language to render the problem in (if supported) | affects the translation of template strings, _not_ actual problem content |
-| \_format | string | 'html' | Determine how the response is _structured_ ('html' or 'json') | usually 'html' if the user is directly interacting with the renderer, 'json' if your CMS sits between user and renderer |
-| outputFormat | string | 'default' | Determines how the problem should be formatted | 'default', 'static', 'PTX', 'raw', or |
-| displayMode | string | 'MathJax' | How to prepare math content for display | 'MathJax' or 'ptx' |
+| Key | Type | Default Value | Description | Notes |
+| --- | ---- | ------------- | ----------- | ----- |
+| language | string | en | Language to render the problem in (if supported) | affects the translation of template strings, _not_ actual problem content |
+| _format | string | 'html' | Determine how the response is _structured_ ('html' or 'json') | usually 'html' if the user is directly interacting with the renderer, 'json' if your CMS sits between user and renderer |
+| outputFormat | string | 'default' | Determines how the problem should be formatted | 'default', 'static', 'PTX', 'raw', or |
+| displayMode | string | 'MathJax' | How to prepare math content for display | 'MathJax' or 'ptx' |
#### User Interactions
@@ -137,66 +214,56 @@ Control how the user is allowed to interact with the rendered problem.
Requesting `outputFormat: 'static'` will prevent any buttons from being included in the rendered output, regardless of the following options.
-| Key | Type | Default Value | Description | Notes |
-| ------------------------ | ---------------- | -------------- | -------------------------------------------------------------------------------------------- | ----- |
-| hidePreviewButton | number (boolean) | false | "Preview My Answers" is enabled by default | |
-| hideCheckAnswersButton | number (boolean) | false | "Submit Answers" is enabled by default | |
-| showCorrectAnswersButton | number (boolean) | `isInstructor` | "Show Correct Answers" is disabled by default, enabled if `isInstructor` is true (see below) | |
+| Key | Type | Default Value | Description | Notes |
+| --- | ---- | ------------- | ----------- | ----- |
+| hidePreviewButton | number (boolean) | false | "Preview My Answers" is enabled by default | |
+| hideCheckAnswersButton | number (boolean) | false | "Submit Answers" is enabled by default | |
+| showCorrectAnswersButton | number (boolean) | `isInstructor` | "Show Correct Answers" is disabled by default, enabled if `isInstructor` is true (see below) | |
#### Content
Control what is shown to the user: hints, solutions, attempt results, scores, etc.
-| Key | Type | Default Value | Description | Notes |
-| ----------------- | ---------------- | -------------- | ------------------------------------------------------------------------------- | --------------------------------------------------------------------------------- |
-| permissionLevel | number | 0 | **DEPRECATED.** Use `isInstructor` instead. | |
-| isInstructor | number (boolean) | 0 | Is the user viewing the problem an instructor or not. | Used by PG to determine if scaffolds can be allowed to be open among other things |
-| showHints | number (boolean) | 1 | Whether or not to show hints | |
-| showSolutions | number (boolean) | `isInstructor` | Whether or not to show the solutions | |
-| hideAttemptsTable | number (boolean) | 0 | Hide the table of answer previews/results/messages | If you have a replacement for flagging the submitted entries as correct/incorrect |
-| showSummary | number (boolean) | 1 | Determines whether or not to show a summary of the attempt underneath the table | Only relevant if the Attempts Table is shown `hideAttemptsTable: false` (default) |
-| showComments | number (boolean) | 0 | Renders author comment field at the end of the problem | |
-| showFooter | number (boolean) | 0 | Show version information and WeBWorK copyright footer | |
-| includeTags | number (boolean) | 0 | Includes problem tags in the returned JSON | Only relevant when requesting `_format: 'json'` |
+| Key | Type | Default Value | Description | Notes |
+| --- | ---- | ------------- | ----------- | ----- |
+| permissionLevel | number | 0 | **DEPRECATED.** Use `isInstructor` instead. |
+| isInstructor | number (boolean) | 0 | Is the user viewing the problem an instructor or not. | Used by PG to determine if scaffolds can be allowed to be open among other things |
+| showHints | number (boolean) | 1 | Whether or not to show hints | |
+| showSolutions | number (boolean) | `isInstructor` | Whether or not to show the solutions | |
+| hideAttemptsTable | number (boolean) | 0 | Hide the table of answer previews/results/messages | If you have a replacement for flagging the submitted entries as correct/incorrect |
+| showSummary | number (boolean) | 1 | Determines whether or not to show a summary of the attempt underneath the table | Only relevant if the Attempts Table is shown `hideAttemptsTable: false` (default) |
+| showComments | number (boolean) | 0 | Renders author comment field at the end of the problem | |
+| showFooter | number (boolean) | 0 | Show version information and WeBWorK copyright footer | |
+| includeTags | number (boolean) | 0 | Includes problem tags in the returned JSON | Only relevant when requesting `_format: 'json'` |
## Using JWTs
There are three JWT structures that the Renderer uses, each containing its predecessor:
-
-- problemJWT
-- sessionJWT
-- answerJWT
+* problemJWT
+* sessionJWT
+* answerJWT
### ProblemJWT
-This JWT encapsulates the request parameters described above, under the API heading. Any value set in the JWT cannot be
-overridden by form-data. For example, if the problemJWT includes `isInstructor: 0`, then any subsequent interaction with
-the problem rendered by this JWT cannot override this setting by including `isInstructor: 1` in the form-data.
+This JWT encapsulates the request parameters described above, under the API heading. Any value set in the JWT cannot be overridden by form-data. For example, if the problemJWT includes `isInstructor: 0`, then any subsequent interaction with the problem rendered by this JWT cannot override this setting by including `isInstructor: 1` in the form-data.
### SessionJWT
This JWT encapsulates a user's attempt on a problem, including:
+* the text and LaTeX versions of each answer entry
+* count of incorrect attempts (stopping after a correct attempt, or after `showCorrectAnswers` is used)
+* the problemJWT
-- the text and LaTeX versions of each answer entry
-- count of incorrect attempts (stopping after a correct attempt, or after `showCorrectAnswers` is used)
-- the problemJWT
-
-If stored (see next), this JWT can be submitted as the sole request parameter, and the response will effectively restore
-the users current state of interaction with the problem (as of their last submission).
+If stored (see next), this JWT can be submitted as the sole request parameter, and the response will effectively restore the users current state of interaction with the problem (as of their last submission).
### AnswerJWT
-If the initial problemJWT contains a value for `JWTanswerURL`, this JWT will be generated and sent to the specified URL.
-The answerJWT is the only content provided to the URL. The renderer is intended to to be user-agnostic. It is
-recommended that the JWTanswerURL specify the unique identifier for the user/problem combination. (e.g. `JWTanswerURL:
-'https://db.yoursite.org/grades-api/:user_problem_id'`)
+If the initial problemJWT contains a value for `JWTanswerURL`, this JWT will be generated and sent to the specified URL. The answerJWT is the only content provided to the URL. The renderer is intended to to be user-agnostic. It is recommended that the JWTanswerURL specify the unique identifier for the user/problem combination. (e.g. `JWTanswerURL: 'https://db.yoursite.org/grades-api/:user_problem_id'`)
For security purposes, this parameter is only accepted when included as part of a JWT.
This JWT encapsulates the status of the user's interaction with the problem.
+* score
+* sessionJWT
-- score
-- sessionJWT
-
-The goal here is to update the `JWTanswerURL` with the score and "state" for the user. If you have uses for additional
-information, please feel free to suggest as a GitHub Issue.
+The goal here is to update the `JWTanswerURL` with the score and "state" for the user. If you have uses for additional information, please feel free to suggest as a GitHub Issue.
diff --git a/conf/pg_config.yml b/conf/pg_config.yml
index 84800d8c6..f1cb7aed2 100644
--- a/conf/pg_config.yml
+++ b/conf/pg_config.yml
@@ -87,7 +87,7 @@ externalPrograms:
curl: /usr/bin/curl
tar: /bin/tar
latex: /usr/bin/latex --no-shell-escape
- pdflatex: /usr/bin/pdflatex --no-shell-escape
+ latex2pdf: /usr/bin/xelatex --no-shell-escape
dvisvgm: /usr/bin/dvisvgm
pdf2svg: /usr/bin/pdf2svg
convert: /usr/bin/convert
@@ -142,16 +142,10 @@ specialPGEnvironmentVars:
# To enable Rserve (the R statistical server), uncomment the following two
# lines. The R server needs to be installed and running in order for this to
- # work. See http://webwork.maa.org/wiki/R_in_WeBWorK for more info.
+ # work. See https://wiki.openwebwork.org/wiki/R_in_WeBWorK for more info.
#Rserve:
# host: localhost
- # Locations of CAPA resources. (Only necessary if you need to use converted CAPA problems.)
- CAPA_Tools: $Contrib_dir/CAPA/macros/CAPA_Tools/
- CAPA_MCTools: $Contrib_dir/Contrib/CAPA/macros/CAPA_MCTools/
- CAPA_GraphicsDirectory: $Contrib_dir/Contrib/CAPA/CAPA_Graphics/
- CAPA_Graphics_URL: $pg_root_url/CAPA_Graphics/
-
# Answer evaluatior defaults
ansEvalDefaults:
functAbsTolDefault: 0.001
@@ -237,7 +231,6 @@ modules:
- [Multiple]
- [PGrandom]
- [Regression]
- - ['Plots::Plot', 'Plots::Axes', 'Plots::Data', 'Plots::Tikz', 'Plots::JSXGraph', 'Plots::GD']
- [Select]
- [Units]
- [VectorField]
@@ -245,6 +238,7 @@ modules:
- [Value]
- ['Parser::Legacy']
- [Statistics]
+ - ['Plots::Plot', 'Plots::Axes', 'Plots::Data', 'Plots::Tikz', 'Plots::JSXGraph']
- [Chromatic] # for Northern Arizona graph problems
- [Applet]
- [PGcore]
diff --git a/cpanfile b/cpanfile
new file mode 100644
index 000000000..89a0ccf19
--- /dev/null
+++ b/cpanfile
@@ -0,0 +1,14 @@
+# Perl dependencies installed via cpanm (beyond Ubuntu apt packages).
+# Pin versions for reproducible builds.
+#
+# To update: build without pins, check versions with:
+# docker run --rm renderer perl -MModule::Name -e 'print $Module::Name::VERSION'
+# Then update the version numbers here.
+
+requires 'Mojolicious', '== 9.42';
+requires 'Statistics::R::IO::Rserve', '== 1.0002';
+requires 'Date::Format', '== 2.24';
+requires 'Future::AsyncAwait', '== 0.71';
+requires 'Crypt::JWT', '== 0.037';
+requires 'IO::Socket::SSL', '== 2.098';
+requires 'CGI::Cookie', '== 4.59';
diff --git a/lib/PG b/lib/PG
index b35f650fa..745e7eb33 160000
--- a/lib/PG
+++ b/lib/PG
@@ -1 +1 @@
-Subproject commit b35f650facb888dff6af11fdc66207d6993a63ed
+Subproject commit 745e7eb3385ba84e69713c4ce1bbed5d8bb8a3fb
diff --git a/t/ci/01-smoke.sh b/t/ci/01-smoke.sh
new file mode 100755
index 000000000..57e80fe7d
--- /dev/null
+++ b/t/ci/01-smoke.sh
@@ -0,0 +1,47 @@
+#!/usr/bin/env bash
+# 01-smoke.sh — Health check, basic render, response structure validation.
+
+set -euo pipefail
+SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
+source "$SCRIPT_DIR/lib/helpers.sh"
+source "$SCRIPT_DIR/lib/problems.sh"
+
+echo "=== Smoke Tests ==="
+
+# Health endpoint
+assert_status "GET" "/health" "200" "GET /health returns 200"
+
+# Editor UI available in dev mode
+assert_status "GET" "/" "200" "GET / (editor UI) returns 200 in dev mode"
+
+# Basic render
+RESP=$(render_raw "problemSource=${PROBLEM_BASIC}" "problemSeed=42")
+
+assert_json_field "$RESP" '.renderedHTML' "Response has .renderedHTML"
+assert_json_field "$RESP" '.JWT.problem' "Response has .JWT.problem (non-empty)"
+assert_json_field "$RESP" '.JWT.session' "Response has .JWT.session (non-empty)"
+
+# Score should be 0 (no answers submitted)
+SCORE=$(echo "$RESP" | jq -r '.problem_result.score // empty')
+assert_eq "$SCORE" "0" "Score is 0 with no answers submitted"
+
+# Debug block should exist
+assert_json_field "$RESP" '.debug' "Response has .debug block"
+
+# Resources block should exist
+assert_json_field "$RESP" '.resources' "Response has .resources block"
+
+# renderedHTML should contain a form input for the answer
+HTML=$(echo "$RESP" | jq -r '.renderedHTML')
+assert_contains "$HTML" "input" "renderedHTML contains input element"
+
+# Instructor mode adds answers and inputs to response
+RESP_INST=$(render_raw "problemSource=${PROBLEM_BASIC}" "problemSeed=42" "isInstructor=1")
+assert_json_field "$RESP_INST" '.answers' "Instructor response has .answers"
+assert_json_field "$RESP_INST" '.inputs' "Instructor response has .inputs"
+
+# Non-instructor should NOT have answers
+NO_ANSWERS=$(echo "$RESP" | jq -r '.answers // empty')
+assert_eq "$NO_ANSWERS" "" "Non-instructor response has no .answers"
+
+summary "smoke tests"
diff --git a/t/ci/02-render-parity.sh b/t/ci/02-render-parity.sh
new file mode 100755
index 000000000..4fd5aadff
--- /dev/null
+++ b/t/ci/02-render-parity.sh
@@ -0,0 +1,76 @@
+#!/usr/bin/env bash
+# 02-render-parity.sh — Verify raw params vs JWT produce identical rendered output.
+
+set -euo pipefail
+SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
+source "$SCRIPT_DIR/lib/helpers.sh"
+source "$SCRIPT_DIR/lib/problems.sh"
+
+echo "=== Render Parity Tests ==="
+
+# Helper: compare raw render vs JWE render for a given set of params.
+# Usage: parity_test DESCRIPTION PARAM...
+parity_test_jwe() {
+ local desc="$1"
+ shift
+ local raw_resp jwe_resp raw_hash jwe_hash
+
+ raw_resp=$(render_raw "$@")
+ jwe_resp=$(render_via_jwe "$@")
+
+ raw_hash=$(hash_html "$raw_resp")
+ jwe_hash=$(hash_html "$jwe_resp")
+
+ assert_eq "$jwe_hash" "$raw_hash" "JWE parity: $desc"
+}
+
+parity_test_jws() {
+ local desc="$1"
+ shift
+ local raw_resp jws_resp raw_hash jws_hash
+
+ raw_resp=$(render_raw "$@")
+ jws_resp=$(render_via_jws "$@")
+
+ raw_hash=$(hash_html "$raw_resp")
+ jws_hash=$(hash_html "$jws_resp")
+
+ assert_eq "$jws_hash" "$raw_hash" "JWS parity: $desc"
+}
+
+# Basic problem, default params
+parity_test_jwe "basic problem, defaults" \
+ "problemSource=${PROBLEM_BASIC}" "problemSeed=42"
+
+# Basic problem with isInstructor
+parity_test_jwe "basic problem, isInstructor=1" \
+ "problemSource=${PROBLEM_BASIC}" "problemSeed=42" "isInstructor=1"
+
+# Basic problem with showCorrectAnswers
+parity_test_jwe "basic problem, instructor + showCorrectAnswers" \
+ "problemSource=${PROBLEM_BASIC}" "problemSeed=42" "isInstructor=1" "showCorrectAnswers=1"
+
+# Seed-sensitive problem with seed=42
+parity_test_jwe "random problem, seed=42" \
+ "problemSource=${PROBLEM_RANDOM}" "problemSeed=42"
+
+# Seed-sensitive problem with seed=99999
+parity_test_jwe "random problem, seed=99999" \
+ "problemSource=${PROBLEM_RANDOM}" "problemSeed=99999"
+
+# Multi-answer problem
+parity_test_jwe "multi-answer problem" \
+ "problemSource=${PROBLEM_MULTI}" "problemSeed=42"
+
+# JWS parity (HS256 signed instead of encrypted)
+parity_test_jws "basic problem via JWS" \
+ "problemSource=${PROBLEM_BASIC}" "problemSeed=42"
+
+# Verify different seeds produce different output
+RESP_42=$(render_raw "problemSource=${PROBLEM_RANDOM}" "problemSeed=42")
+RESP_99=$(render_raw "problemSource=${PROBLEM_RANDOM}" "problemSeed=99999")
+HASH_42=$(hash_html "$RESP_42")
+HASH_99=$(hash_html "$RESP_99")
+assert_ne "$HASH_42" "$HASH_99" "Different seeds produce different renderedHTML"
+
+summary "render parity tests"
diff --git a/t/ci/03-answer-cycle.sh b/t/ci/03-answer-cycle.sh
new file mode 100755
index 000000000..79d32df07
--- /dev/null
+++ b/t/ci/03-answer-cycle.sh
@@ -0,0 +1,113 @@
+#!/usr/bin/env bash
+# 03-answer-cycle.sh — Render → submit → verify scoring.
+
+set -euo pipefail
+SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
+source "$SCRIPT_DIR/lib/helpers.sh"
+source "$SCRIPT_DIR/lib/problems.sh"
+
+echo "=== Answer Cycle Tests ==="
+
+# ── Single answer: correct ────────────────────────────────────
+
+# Step 1: Render as instructor to discover answer field name + correct value
+INST_RESP=$(render_raw "problemSource=${PROBLEM_BASIC}" "problemSeed=42" "isInstructor=1")
+
+# Extract the answer field name (e.g., AnSwEr0001)
+ANS_NAME=$(echo "$INST_RESP" | jq -r '.answers | keys[0]')
+CORRECT_VAL=$(echo "$INST_RESP" | jq -r ".answers.\"$ANS_NAME\".correct_ans")
+
+assert_ne "$ANS_NAME" "" "Discovered answer field name: $ANS_NAME"
+assert_ne "$CORRECT_VAL" "" "Discovered correct answer: $CORRECT_VAL"
+
+# Step 2: Render as student to get JWT tokens
+STU_RESP=$(render_raw "problemSource=${PROBLEM_BASIC}" "problemSeed=42")
+PROB_JWT=$(echo "$STU_RESP" | jq -r '.JWT.problem')
+SESS_JWT=$(echo "$STU_RESP" | jq -r '.JWT.session')
+
+# Step 3: Submit correct answer
+SUBMIT_RESP=$(curl -sf --max-time "$CURL_TIMEOUT" -X POST \
+ -d "problemJWT=${PROB_JWT}" \
+ -d "sessionJWT=${SESS_JWT}" \
+ -d "${ANS_NAME}=${CORRECT_VAL}" \
+ -d "submitAnswers=1" \
+ -d "answersSubmitted=1" \
+ -d "_format=json" \
+ "${BASE_URL}/render-api" 2>/dev/null)
+
+SCORE=$(echo "$SUBMIT_RESP" | jq -r '.problem_result.score')
+assert_eq "$SCORE" "1" "Correct answer scores 1"
+
+# Step 4: Submit wrong answer
+WRONG_RESP=$(curl -sf --max-time "$CURL_TIMEOUT" -X POST \
+ -d "problemJWT=${PROB_JWT}" \
+ -d "sessionJWT=${SESS_JWT}" \
+ -d "${ANS_NAME}=999" \
+ -d "submitAnswers=1" \
+ -d "answersSubmitted=1" \
+ -d "_format=json" \
+ "${BASE_URL}/render-api" 2>/dev/null)
+
+WRONG_SCORE=$(echo "$WRONG_RESP" | jq -r '.problem_result.score')
+assert_eq "$WRONG_SCORE" "0" "Wrong answer scores 0"
+
+# ── Multi-answer: partial credit ──────────────────────────────
+
+MULTI_INST=$(render_raw "problemSource=${PROBLEM_MULTI}" "problemSeed=42" "isInstructor=1")
+
+# Get both answer field names and correct values
+ANS1_NAME=$(echo "$MULTI_INST" | jq -r '.answers | keys[0]')
+ANS2_NAME=$(echo "$MULTI_INST" | jq -r '.answers | keys[1]')
+ANS1_CORRECT=$(echo "$MULTI_INST" | jq -r ".answers.\"$ANS1_NAME\".correct_ans")
+ANS2_CORRECT=$(echo "$MULTI_INST" | jq -r ".answers.\"$ANS2_NAME\".correct_ans")
+
+# Render as student
+MULTI_STU=$(render_raw "problemSource=${PROBLEM_MULTI}" "problemSeed=42")
+MULTI_PROB_JWT=$(echo "$MULTI_STU" | jq -r '.JWT.problem')
+MULTI_SESS_JWT=$(echo "$MULTI_STU" | jq -r '.JWT.session')
+
+# Submit 1/2 correct (first correct, second wrong)
+PARTIAL_RESP=$(curl -sf --max-time "$CURL_TIMEOUT" -X POST \
+ -d "problemJWT=${MULTI_PROB_JWT}" \
+ -d "sessionJWT=${MULTI_SESS_JWT}" \
+ -d "${ANS1_NAME}=${ANS1_CORRECT}" \
+ -d "${ANS2_NAME}=999" \
+ -d "submitAnswers=1" \
+ -d "answersSubmitted=1" \
+ -d "_format=json" \
+ "${BASE_URL}/render-api" 2>/dev/null)
+
+PARTIAL_SCORE=$(echo "$PARTIAL_RESP" | jq -r '.problem_result.score')
+assert_eq "$PARTIAL_SCORE" "0.5" "1/2 correct scores 0.5"
+
+# Submit both correct
+FULL_RESP=$(curl -sf --max-time "$CURL_TIMEOUT" -X POST \
+ -d "problemJWT=${MULTI_PROB_JWT}" \
+ -d "sessionJWT=${MULTI_SESS_JWT}" \
+ -d "${ANS1_NAME}=${ANS1_CORRECT}" \
+ -d "${ANS2_NAME}=${ANS2_CORRECT}" \
+ -d "submitAnswers=1" \
+ -d "answersSubmitted=1" \
+ -d "_format=json" \
+ "${BASE_URL}/render-api" 2>/dev/null)
+
+FULL_SCORE=$(echo "$FULL_RESP" | jq -r '.problem_result.score')
+assert_eq "$FULL_SCORE" "1" "2/2 correct scores 1"
+
+# ── Preview mode ──────────────────────────────────────────────
+
+# Preview mode renders the formatted input. The grader still runs (score reflects
+# correctness), but the rendered HTML shows formatted input rather than right/wrong.
+PREVIEW_RESP=$(curl -sf --max-time "$CURL_TIMEOUT" -X POST \
+ -d "problemJWT=${PROB_JWT}" \
+ -d "sessionJWT=${SESS_JWT}" \
+ -d "${ANS_NAME}=${CORRECT_VAL}" \
+ -d "previewAnswers=1" \
+ -d "answersSubmitted=1" \
+ -d "_format=json" \
+ "${BASE_URL}/render-api" 2>/dev/null)
+
+assert_json_field "$PREVIEW_RESP" '.renderedHTML' "Preview mode returns rendered HTML"
+assert_json_field "$PREVIEW_RESP" '.JWT.session' "Preview mode returns session JWT"
+
+summary "answer cycle tests"
diff --git a/t/ci/04-endpoints.sh b/t/ci/04-endpoints.sh
new file mode 100755
index 000000000..77f431229
--- /dev/null
+++ b/t/ci/04-endpoints.sh
@@ -0,0 +1,117 @@
+#!/usr/bin/env bash
+# 04-endpoints.sh — Static assets, IO routes, error handling.
+
+set -euo pipefail
+SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
+source "$SCRIPT_DIR/lib/helpers.sh"
+source "$SCRIPT_DIR/lib/problems.sh"
+
+echo "=== Endpoint Tests ==="
+
+# ── Static Assets ─────────────────────────────────────────────
+
+# MathJax should be available under pg_files
+MATHJAX_STATUS=$(curl -s -o /dev/null -w '%{http_code}' --max-time "$CURL_TIMEOUT" \
+ "${BASE_URL}/pg_files/MathJax/es5/tex-chtml.js" 2>/dev/null)
+# MathJax may be served from CDN instead of locally — 200 or 404 are both valid
+(( _TOTAL++ ))
+if [[ "$MATHJAX_STATUS" == "200" || "$MATHJAX_STATUS" == "404" ]]; then
+ (( _PASS++ ))
+ printf "${GREEN}ok %d${NC} - MathJax endpoint responded (%s)\n" "$_TOTAL" "$MATHJAX_STATUS"
+else
+ (( _FAIL++ ))
+ printf "${RED}not ok %d${NC} - MathJax endpoint returned unexpected %s\n" "$_TOTAL" "$MATHJAX_STATUS"
+fi
+
+# ── IO Routes (dev mode) ─────────────────────────────────────
+
+# Catalog: list private/ directory
+CAT_STATUS=$(http_status "POST" "/render-api/cat" "basePath=private")
+# If private/ dir doesn't exist in the container, 500 is acceptable
+(( _TOTAL++ ))
+if [[ "$CAT_STATUS" == "200" || "$CAT_STATUS" == "500" ]]; then
+ (( _PASS++ ))
+ printf "${GREEN}ok %d${NC} - /render-api/cat responded (%s)\n" "$_TOTAL" "$CAT_STATUS"
+else
+ (( _FAIL++ ))
+ printf "${RED}not ok %d${NC} - /render-api/cat returned unexpected %s\n" "$_TOTAL" "$CAT_STATUS"
+fi
+
+# Tap: read test fixture (mounted at private/test/)
+TAP_RESP=$(curl -sf --max-time "$CURL_TIMEOUT" -X POST \
+ -d "sourceFilePath=private/test/test-problem.pg" \
+ "${BASE_URL}/render-api/tap" 2>/dev/null || true)
+
+if [[ -n "$TAP_RESP" ]]; then
+ assert_contains "$TAP_RESP" "DOCUMENT" "/render-api/tap returns PG source"
+else
+ # If fixture wasn't mounted, mark as skip
+ (( _TOTAL++ ))
+ (( _PASS++ ))
+ printf "${YELLOW}ok %d${NC} - /render-api/tap (skipped — no fixture mount)\n" "$_TOTAL"
+fi
+
+# ── Error Handling ────────────────────────────────────────────
+
+# Missing problem source → should not crash (returns error JSON or 500)
+MISSING_STATUS=$(curl -s -o /dev/null -w '%{http_code}' --max-time "$CURL_TIMEOUT" \
+ -X POST -d "_format=json" \
+ "${BASE_URL}/render-api" 2>/dev/null)
+(( _TOTAL++ ))
+if [[ "$MISSING_STATUS" =~ ^[45][0-9][0-9]$ ]]; then
+ (( _PASS++ ))
+ printf "${GREEN}ok %d${NC} - Missing problem returns error status (%s)\n" "$_TOTAL" "$MISSING_STATUS"
+else
+ (( _FAIL++ ))
+ printf "${RED}not ok %d${NC} - Missing problem returned unexpected %s\n" "$_TOTAL" "$MISSING_STATUS"
+fi
+
+# Nonexistent file path
+NOFILE_RESP=$(curl -s -w '\n%{http_code}' --max-time "$CURL_TIMEOUT" \
+ -X POST \
+ -d "sourceFilePath=private/does/not/exist.pg" \
+ -d "_format=json" \
+ "${BASE_URL}/render-api" 2>/dev/null)
+NOFILE_STATUS=$(echo "$NOFILE_RESP" | tail -1)
+(( _TOTAL++ ))
+if [[ "$NOFILE_STATUS" =~ ^[45][0-9][0-9]$ ]]; then
+ (( _PASS++ ))
+ printf "${GREEN}ok %d${NC} - Nonexistent file path returns error (%s)\n" "$_TOTAL" "$NOFILE_STATUS"
+else
+ (( _FAIL++ ))
+ printf "${RED}not ok %d${NC} - Nonexistent file path returned %s\n" "$_TOTAL" "$NOFILE_STATUS"
+fi
+
+# Simultaneous submit + preview → 400
+SIMUL_STATUS=$(curl -s -o /dev/null -w '%{http_code}' --max-time "$CURL_TIMEOUT" \
+ -X POST \
+ --data-urlencode "problemSource=${PROBLEM_BASIC}" \
+ --data-urlencode "submitAnswers=1" \
+ --data-urlencode "previewAnswers=1" \
+ --data-urlencode "_format=json" \
+ "${BASE_URL}/render-api" 2>/dev/null)
+assert_eq "$SIMUL_STATUS" "400" "Simultaneous submit + preview returns 400"
+
+# Malformed JWT → error (not crash)
+BADJWT_STATUS=$(curl -s -o /dev/null -w '%{http_code}' --max-time "$CURL_TIMEOUT" \
+ -X POST \
+ -d "problemJWT=this.is.garbage" \
+ -d "_format=json" \
+ "${BASE_URL}/render-api" 2>/dev/null)
+(( _TOTAL++ ))
+if [[ "$BADJWT_STATUS" =~ ^[45][0-9][0-9]$ ]]; then
+ (( _PASS++ ))
+ printf "${GREEN}ok %d${NC} - Malformed JWT returns error (%s)\n" "$_TOTAL" "$BADJWT_STATUS"
+else
+ (( _FAIL++ ))
+ printf "${RED}not ok %d${NC} - Malformed JWT returned %s (expected 4xx/5xx)\n" "$_TOTAL" "$BADJWT_STATUS"
+fi
+
+# ── Timeout endpoint (dev mode) ──────────────────────────────
+
+# /timeout should respond with 200 after ~2s delay
+TIMEOUT_STATUS=$(curl -s -o /dev/null -w '%{http_code}' --max-time 10 \
+ "${BASE_URL}/timeout" 2>/dev/null)
+assert_eq "$TIMEOUT_STATUS" "200" "/timeout responds 200 after delay"
+
+summary "endpoint tests"
diff --git a/t/ci/fixtures/test-problem.pg b/t/ci/fixtures/test-problem.pg
new file mode 100644
index 000000000..58b640fc2
--- /dev/null
+++ b/t/ci/fixtures/test-problem.pg
@@ -0,0 +1,15 @@
+DOCUMENT();
+
+loadMacros('PGstandard.pl', 'MathObjects.pl', 'PGML.pl');
+
+Context("Numeric");
+
+$answer = Real('pi');
+
+BEGIN_PGML
+Enter a value for [`\pi`].
+
+[_____]{$answer}
+END_PGML
+
+ENDDOCUMENT();
diff --git a/t/ci/lib/helpers.sh b/t/ci/lib/helpers.sh
new file mode 100755
index 000000000..828788cdf
--- /dev/null
+++ b/t/ci/lib/helpers.sh
@@ -0,0 +1,219 @@
+#!/usr/bin/env bash
+# helpers.sh — Shared test utilities for renderer CI tests
+# Provides HTTP helpers, assertion functions, and TAP-style output.
+
+set -euo pipefail
+
+BASE_URL="${BASE_URL:-http://localhost:3000}"
+CURL_TIMEOUT="${CURL_TIMEOUT:-30}"
+
+# Counters
+_PASS=0
+_FAIL=0
+_TOTAL=0
+
+# Colors (disabled if not a terminal)
+if [[ -t 1 ]]; then
+ GREEN='\033[0;32m'
+ RED='\033[0;31m'
+ YELLOW='\033[0;33m'
+ NC='\033[0m'
+else
+ GREEN='' RED='' YELLOW='' NC=''
+fi
+
+# ── HTTP Helpers ──────────────────────────────────────────────
+
+# render_raw PARAM=VALUE ...
+# POST form params to /render-api with _format=json. Prints JSON response.
+render_raw() {
+ local data=()
+ for param in "$@"; do
+ data+=(--data-urlencode "$param")
+ done
+ data+=(--data-urlencode "_format=json")
+ curl -sf --max-time "$CURL_TIMEOUT" -X POST "${data[@]}" "${BASE_URL}/render-api" 2>/dev/null
+}
+
+# render_via_jwe PARAM=VALUE ...
+# First get JWE from /render-api/jwe, then render with problemJWT=.
+render_via_jwe() {
+ local data=()
+ for param in "$@"; do
+ data+=(--data-urlencode "$param")
+ done
+ local token
+ token=$(curl -sf --max-time "$CURL_TIMEOUT" -X POST "${data[@]}" "${BASE_URL}/render-api/jwe" 2>/dev/null)
+ if [[ -z "$token" ]]; then
+ echo "ERROR: Failed to get JWE token" >&2
+ return 1
+ fi
+ curl -sf --max-time "$CURL_TIMEOUT" -X POST \
+ -d "problemJWT=${token}" \
+ -d "_format=json" \
+ "${BASE_URL}/render-api" 2>/dev/null
+}
+
+# render_via_jws PARAM=VALUE ...
+# Same as render_via_jwe but uses /render-api/jwt (HS256 signed).
+render_via_jws() {
+ local data=()
+ for param in "$@"; do
+ data+=(--data-urlencode "$param")
+ done
+ local token
+ token=$(curl -sf --max-time "$CURL_TIMEOUT" -X POST "${data[@]}" "${BASE_URL}/render-api/jwt" 2>/dev/null)
+ if [[ -z "$token" ]]; then
+ echo "ERROR: Failed to get JWS token" >&2
+ return 1
+ fi
+ curl -sf --max-time "$CURL_TIMEOUT" -X POST \
+ -d "problemJWT=${token}" \
+ -d "_format=json" \
+ "${BASE_URL}/render-api" 2>/dev/null
+}
+
+# http_status METHOD URL [DATA_PARAMS...]
+# Returns HTTP status code only.
+http_status() {
+ local method="$1" url="$2"
+ shift 2
+ local data=()
+ for param in "$@"; do
+ data+=(--data-urlencode "$param")
+ done
+ curl -s -o /dev/null -w '%{http_code}' --max-time "$CURL_TIMEOUT" \
+ -X "$method" ${data[@]+"${data[@]}"} "${BASE_URL}${url}" 2>/dev/null
+}
+
+# http_get URL
+# GET request, returns body.
+http_get() {
+ curl -sf --max-time "$CURL_TIMEOUT" "${BASE_URL}$1" 2>/dev/null
+}
+
+# ── Normalization ─────────────────────────────────────────────
+
+# normalize_html HTML_STRING
+# Reduces HTML to tag structure + text content, stripping attributes.
+# This avoids false diffs from non-deterministic attribute ordering
+# (Perl hash iteration order) while preserving structural differences
+# like instructor-only elements, extra inputs, etc.
+#
+# Specifically:
+# 1. Remove hidden inputs entirely (JWT tokens, session state)
+# 2. Replace opening tags with just tag names:
→
+# 3. Collapse whitespace
+normalize_html() {
+ local html="$1"
+ echo "$html" \
+ | sed -E 's/]*type="hidden"[^>]*>//g' \
+ | sed -E 's/<(\/?)([a-zA-Z][a-zA-Z0-9]*)[^>]*>/<\1\2>/g' \
+ | tr -s '[:space:]' ' ' \
+ | sed 's/^ //;s/ $//'
+}
+
+# hash_html JSON_RESPONSE
+# Extract .renderedHTML, normalize, sha256sum.
+hash_html() {
+ local json="$1"
+ local html
+ html=$(echo "$json" | jq -r '.renderedHTML // empty')
+ if [[ -z "$html" ]]; then
+ echo "ERROR: No renderedHTML in response" >&2
+ return 1
+ fi
+ normalize_html "$html" | shasum -a 256 | awk '{print $1}'
+}
+
+# ── Assertions ────────────────────────────────────────────────
+
+# assert_eq ACTUAL EXPECTED DESCRIPTION
+assert_eq() {
+ local actual="$1" expected="$2" desc="$3"
+ (( _TOTAL++ ))
+ if [[ "$actual" == "$expected" ]]; then
+ (( _PASS++ ))
+ printf "${GREEN}ok %d${NC} - %s\n" "$_TOTAL" "$desc"
+ else
+ (( _FAIL++ ))
+ printf "${RED}not ok %d${NC} - %s\n" "$_TOTAL" "$desc"
+ printf " expected: %s\n got: %s\n" "$expected" "$actual"
+ fi
+}
+
+# assert_ne ACTUAL UNEXPECTED DESCRIPTION
+assert_ne() {
+ local actual="$1" unexpected="$2" desc="$3"
+ (( _TOTAL++ ))
+ if [[ "$actual" != "$unexpected" ]]; then
+ (( _PASS++ ))
+ printf "${GREEN}ok %d${NC} - %s\n" "$_TOTAL" "$desc"
+ else
+ (( _FAIL++ ))
+ printf "${RED}not ok %d${NC} - %s\n" "$_TOTAL" "$desc"
+ printf " expected anything except: %s\n" "$unexpected"
+ fi
+}
+
+# assert_status METHOD URL EXPECTED_STATUS DESCRIPTION [DATA_PARAMS...]
+assert_status() {
+ local method="$1" url="$2" expected="$3" desc="$4"
+ shift 4
+ local status
+ status=$(http_status "$method" "$url" "$@")
+ assert_eq "$status" "$expected" "$desc"
+}
+
+# assert_contains HAYSTACK NEEDLE DESCRIPTION
+assert_contains() {
+ local haystack="$1" needle="$2" desc="$3"
+ (( _TOTAL++ ))
+ if [[ "$haystack" == *"$needle"* ]]; then
+ (( _PASS++ ))
+ printf "${GREEN}ok %d${NC} - %s\n" "$_TOTAL" "$desc"
+ else
+ (( _FAIL++ ))
+ printf "${RED}not ok %d${NC} - %s\n" "$_TOTAL" "$desc"
+ printf " expected to contain: %s\n" "$needle"
+ fi
+}
+
+# assert_json_field JSON_STRING JQ_FILTER DESCRIPTION
+# Passes if jq filter returns non-null, non-empty.
+assert_json_field() {
+ local json="$1" filter="$2" desc="$3"
+ local val
+ val=$(echo "$json" | jq -r "$filter // empty" 2>/dev/null)
+ (( _TOTAL++ ))
+ if [[ -n "$val" ]]; then
+ (( _PASS++ ))
+ printf "${GREEN}ok %d${NC} - %s\n" "$_TOTAL" "$desc"
+ else
+ (( _FAIL++ ))
+ printf "${RED}not ok %d${NC} - %s\n" "$_TOTAL" "$desc"
+ printf " jq filter '%s' returned empty\n" "$filter"
+ fi
+}
+
+# assert_json_eq JSON_STRING JQ_FILTER EXPECTED DESCRIPTION
+assert_json_eq() {
+ local json="$1" filter="$2" expected="$3" desc="$4"
+ local val
+ val=$(echo "$json" | jq -r "$filter // empty" 2>/dev/null)
+ assert_eq "$val" "$expected" "$desc"
+}
+
+# ── Summary ───────────────────────────────────────────────────
+
+summary() {
+ local suite="${1:-tests}"
+ echo ""
+ if (( _FAIL > 0 )); then
+ printf "${RED}FAIL${NC}: %d/%d %s passed\n" "$_PASS" "$_TOTAL" "$suite"
+ return 1
+ else
+ printf "${GREEN}PASS${NC}: %d/%d %s passed\n" "$_PASS" "$_TOTAL" "$suite"
+ return 0
+ fi
+}
diff --git a/t/ci/lib/problems.sh b/t/ci/lib/problems.sh
new file mode 100755
index 000000000..fe3125b39
--- /dev/null
+++ b/t/ci/lib/problems.sh
@@ -0,0 +1,44 @@
+#!/usr/bin/env bash
+# problems.sh — Inline PG problem sources for CI tests.
+# Each variable holds a complete PG problem as a string.
+
+# PROBLEM_BASIC: One numeric answer (42), no randomization.
+read -r -d '' PROBLEM_BASIC << 'PGEOF' || true
+DOCUMENT();
+loadMacros('PGstandard.pl', 'MathObjects.pl', 'PGML.pl');
+Context("Numeric");
+$answer = Compute("42");
+BEGIN_PGML
+What is the answer to everything?
+[_____]{$answer}
+END_PGML
+ENDDOCUMENT();
+PGEOF
+
+# PROBLEM_MULTI: Two answer blanks (3 and 5).
+read -r -d '' PROBLEM_MULTI << 'PGEOF' || true
+DOCUMENT();
+loadMacros('PGstandard.pl', 'MathObjects.pl', 'PGML.pl');
+Context("Numeric");
+$a = Compute("3");
+$b = Compute("5");
+BEGIN_PGML
+What is 1+2? [_____]{$a}
+What is 2+3? [_____]{$b}
+END_PGML
+ENDDOCUMENT();
+PGEOF
+
+# PROBLEM_RANDOM: Uses random(), answer = $n^2. Seed-sensitive.
+read -r -d '' PROBLEM_RANDOM << 'PGEOF' || true
+DOCUMENT();
+loadMacros('PGstandard.pl', 'MathObjects.pl', 'PGML.pl');
+Context("Numeric");
+$n = random(2, 20);
+$answer = Compute("$n^2");
+BEGIN_PGML
+What is [$n]^2?
+[_____]{$answer}
+END_PGML
+ENDDOCUMENT();
+PGEOF
diff --git a/t/ci/run-all.sh b/t/ci/run-all.sh
new file mode 100755
index 000000000..98853fec8
--- /dev/null
+++ b/t/ci/run-all.sh
@@ -0,0 +1,143 @@
+#!/usr/bin/env bash
+# run-all.sh — Build, start container, run all test suites, report results.
+# Usage: bash t/ci/run-all.sh [--no-build] [--no-pg-tests]
+#
+# Environment:
+# CONTAINER_NAME — name for the test container (default: renderer-test)
+# IMAGE_NAME — Docker image name (default: renderer-test)
+# BASE_URL — override renderer URL (default: http://localhost:3000)
+# SKIP_BUILD — set to 1 to skip Docker build
+# SKIP_PG_TESTS — set to 1 to skip PG unit tests
+
+set -euo pipefail
+
+SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
+REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
+
+CONTAINER_NAME="${CONTAINER_NAME:-renderer-test}"
+IMAGE_NAME="${IMAGE_NAME:-renderer-test}"
+export BASE_URL="${BASE_URL:-http://localhost:3000}"
+SKIP_BUILD="${SKIP_BUILD:-0}"
+SKIP_PG_TESTS="${SKIP_PG_TESTS:-0}"
+
+# Parse flags
+for arg in "$@"; do
+ case "$arg" in
+ --no-build) SKIP_BUILD=1 ;;
+ --no-pg-tests) SKIP_PG_TESTS=1 ;;
+ esac
+done
+
+OVERALL_EXIT=0
+
+# ── Cleanup on exit ──────────────────────────────────────────
+cleanup() {
+ echo ""
+ echo "=== Cleanup ==="
+ if docker ps -q --filter "name=${CONTAINER_NAME}" | grep -q .; then
+ docker stop "$CONTAINER_NAME" >/dev/null 2>&1 || true
+ fi
+ if docker ps -aq --filter "name=${CONTAINER_NAME}" | grep -q .; then
+ docker rm "$CONTAINER_NAME" >/dev/null 2>&1 || true
+ fi
+ echo "Container cleaned up."
+}
+trap cleanup EXIT
+
+# ── Build ────────────────────────────────────────────────────
+if [[ "$SKIP_BUILD" != "1" ]]; then
+ echo "=== Building Docker image ==="
+ docker build -t "$IMAGE_NAME" "$REPO_ROOT"
+ echo "Build complete."
+else
+ echo "=== Skipping Docker build ==="
+fi
+
+# ── Start Container ──────────────────────────────────────────
+echo ""
+echo "=== Starting container ==="
+
+# Stop any existing container with the same name
+docker rm -f "$CONTAINER_NAME" 2>/dev/null || true
+
+# Use morbo for single-process, deterministic startup.
+# Mount fixtures at private/test/ for IO tests.
+docker run -d \
+ --name "$CONTAINER_NAME" \
+ -p 3000:3000 \
+ -e MOJO_MODE=development \
+ -v "${SCRIPT_DIR}/fixtures:/usr/app/private/test:ro" \
+ "$IMAGE_NAME" \
+ morbo -l 'http://*:3000' ./script/renderer
+
+echo "Container started. Waiting for health..."
+
+# ── Health Poll ──────────────────────────────────────────────
+MAX_ATTEMPTS=30
+POLL_INTERVAL=2
+for i in $(seq 1 $MAX_ATTEMPTS); do
+ if curl -sf --max-time 5 "${BASE_URL}/health" >/dev/null 2>&1; then
+ echo "Renderer healthy after $((i * POLL_INTERVAL))s."
+ break
+ fi
+ if [[ $i -eq $MAX_ATTEMPTS ]]; then
+ echo "FATAL: Renderer failed to start after $((MAX_ATTEMPTS * POLL_INTERVAL))s"
+ echo "Container logs:"
+ docker logs "$CONTAINER_NAME" 2>&1 | tail -50
+ exit 1
+ fi
+ sleep "$POLL_INTERVAL"
+done
+
+# ── PG Unit Tests (informational) ─────────────────────────────
+# PG has its own test suite under lib/PG/t/. We run it here for visibility,
+# but failures do NOT block the renderer CI. PG test health is upstream's
+# responsibility (openwebwork/pg). We skip directories that need external
+# services (R for rserve/, xelatex for tikz_test/) since those aren't
+# installed in the renderer image.
+if [[ "$SKIP_PG_TESTS" != "1" ]]; then
+ echo ""
+ echo "=== PG Unit Tests (informational — does not block CI) ==="
+ if docker exec "$CONTAINER_NAME" bash -c \
+ 'export PG_ROOT=/usr/app/lib/PG && cd $PG_ROOT && prove -lr \
+ t/macros t/contexts t/math_objects t/pg_problems t/units 2>&1'; then
+ echo "PG unit tests passed."
+ else
+ echo "PG unit tests had failures (see above). This is informational only."
+ fi
+else
+ echo ""
+ echo "=== Skipping PG unit tests ==="
+fi
+
+# ── Integration Test Suites ──────────────────────────────────
+run_suite() {
+ local script="$1"
+ local name
+ name="$(basename "$script" .sh)"
+ echo ""
+ echo "────────────────────────────────────────"
+ if bash "$script"; then
+ echo "Suite $name: PASS"
+ else
+ echo "Suite $name: FAIL"
+ OVERALL_EXIT=1
+ fi
+}
+
+run_suite "$SCRIPT_DIR/01-smoke.sh"
+run_suite "$SCRIPT_DIR/02-render-parity.sh"
+run_suite "$SCRIPT_DIR/03-answer-cycle.sh"
+run_suite "$SCRIPT_DIR/04-endpoints.sh"
+
+# ── Final Report ─────────────────────────────────────────────
+echo ""
+echo "════════════════════════════════════════"
+if [[ $OVERALL_EXIT -eq 0 ]]; then
+ echo "ALL SUITES PASSED"
+else
+ echo "SOME SUITES FAILED"
+fi
+echo "════════════════════════════════════════"
+
+exit $OVERALL_EXIT