chore(devcontainer): add Playwright with chromium and a headed noVNC viewer

Mirrors the Model devcontainer's Playwright setup for this Next.js repo, so
agents and humans can drive the running app in a browser.

The image bakes in chromium's apt libraries (as root) and the browser binary
(as vscode, so it lands in that user's cache), meaning a fresh container is
ready to run with no first-use download. The @playwright/test version and the
Dockerfile's PLAYWRIGHT_VERSION are pinned to the same number because Playwright
ties browser builds to a library version.

Also ports Model's headed viewer stack (Xvfb -> fluxbox -> x11vnc -> noVNC on
:6080) as scripts/start_viewer.sh, for watching a headed run.

Cypress is untouched; Playwright specs live in e2e/.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jun-te Kim 2026-07-14 17:15:38 +00:00
parent 769030ab49
commit b914aca3ee
9 changed files with 277 additions and 1 deletions

View file

@ -5,6 +5,11 @@ ARG USER_UID=1000
ARG USER_GID=1000
ARG DEBIAN_FRONTEND=noninteractive
# Keep in lockstep with "@playwright/test" in package.json. Playwright pins its
# browser build to the library version, so a mismatch makes the baked-in
# chromium invisible to the tests ("Executable doesn't exist").
ARG PLAYWRIGHT_VERSION=1.61.1
# Base CLI tooling (sudo, git, ripgrep/fd for editors, etc.).
RUN apt update && apt install -y --no-install-recommends \
sudo jq vim curl bash-completion \
@ -35,6 +40,22 @@ RUN curl -fsSL https://github.com/neovim/neovim/releases/latest/download/nvim-li
| tar -xz -C /opt \
&& ln -s /opt/nvim-linux-x86_64/bin/nvim /usr/local/bin/nvim
# Playwright's OS-level libraries for chromium (fonts, libnss, libasound, ...).
# The npm package is a devDependency; this only pulls the apt side, which needs
# root. The browser binary itself is fetched as ${USER} further down so it lands
# in that user's cache, which is where the tests run from.
RUN npx --yes playwright@${PLAYWRIGHT_VERSION} install-deps chromium \
&& rm -rf /var/lib/apt/lists/*
# Headed-browser viewer stack, mirroring the Model devcontainer. Pipeline:
# Xvfb (virtual :99 screen) -> fluxbox (window manager) -> x11vnc (VNC on 5900)
# -> novnc/websockify (desktop in a browser tab on 6080). Lets you watch and
# click a headed Playwright run; see scripts/start_viewer.sh. Headless runs
# don't need any of this.
RUN apt update && apt install -y --no-install-recommends \
xvfb fluxbox x11vnc novnc websockify \
&& rm -rf /var/lib/apt/lists/*
USER ${USER}
# LazyVim starter config (.git stripped so the user owns the files).
@ -45,6 +66,12 @@ RUN git clone https://github.com/LazyVim/starter /home/${USER}/.config/nvim \
RUN curl -fsSL https://claude.ai/install.sh | bash
ENV PATH="/home/${USER}/.local/bin:${PATH}"
# Chromium browser binary, baked into the image so a fresh container is ready to
# run without a several-hundred-MB download on first use. Cached under
# /home/${USER}/.cache/ms-playwright, which (unlike the workspace) isn't a bind
# mount, so it survives as part of the image.
RUN npx --yes playwright@${PLAYWRIGHT_VERSION} install chromium
USER root
WORKDIR /workspaces/assessment-model

View file

@ -16,7 +16,17 @@
// trial for the home-page redesign; drop its `skills add` step to remove it.
"postCreateCommand": "gh repo clone Hestia-Homes/agentic-toolkit /tmp/agentic-toolkit -- --branch 0.0.8 --depth 1 && bash /tmp/agentic-toolkit/setup.sh && npx --yes skills@latest add pbakaus/impeccable --agent claude-code --copy --global --yes && npm install",
"forwardPorts": ["frontend:3000", "pgadmin:80"],
"forwardPorts": ["frontend:3000", "pgadmin:80", "frontend:6080"],
"portsAttributes": {
"frontend:3000": {
"label": "Next.js"
},
"frontend:6080": {
"label": "Playwright noVNC desktop",
"onAutoForward": "silent"
}
},
"mounts": [
// Optional, just makes getting from Downloads (local env) easier

6
.gitignore vendored
View file

@ -34,6 +34,12 @@ cypress.env.json
# vercel
.vercel
# playwright run artifacts
/test-results/
/playwright-report/
/blob-report/
/playwright/.cache/
# typescript
*.tsbuildinfo
next-env.d.ts

View file

@ -123,6 +123,36 @@ the JWT, and avoid the functionality defined in the signIn function as defined i
**cypress/support/commands.ts** creates a custom login user command which sets a JWT and allows us to actually authenticate. `cy.intercept` only mocks the client side behaviour of the apis and therefore does not set any cookies, do this function does this manually.
# Playwright
Playwright sits alongside Cypress rather than replacing it. Specs live in `e2e/`
(Cypress keeps `cypress/e2e/`), and `playwright.config.ts` boots `next dev` for
you unless something is already serving http://localhost:3000.
```bash
npm run test:playwright
```
The devcontainer image bakes in chromium and its OS libraries, so there is
nothing to install on a fresh container. Chromium is the only browser baked in —
adding firefox or webkit to the config means adding them to
`.devcontainer/Dockerfile` too. The `@playwright/test` version in `package.json`
and `PLAYWRIGHT_VERSION` in that Dockerfile are pinned to the same number on
purpose: Playwright ties each browser build to a library version, so if they
drift, runs fail with "Executable doesn't exist".
## Watching the browser
Runs are headless by default. To watch one drive, start the virtual desktop
(Xvfb -> fluxbox -> x11vnc -> noVNC, mirroring the Model devcontainer), open the
forwarded port 6080 in your browser, then run headed against that display:
```bash
bash scripts/start_viewer.sh
DISPLAY=:99 npm run test:playwright:headed
bash scripts/start_viewer.sh stop
```
# Generating pre-signed urls
In our terraform stack, we have a module called `s3_presignable_bucket` which contains the definition for our bucket which we will use to store retrofit plan input csv's in.

12
e2e/smoke.spec.ts Normal file
View file

@ -0,0 +1,12 @@
import { expect, test } from "@playwright/test";
// Deliberately auth-agnostic: src/middleware.ts bounces unauthenticated
// visitors to a sign-in page, so this asserts only that the app boots and
// serves a page rather than erroring — enough to prove the devcontainer's
// Playwright + chromium wiring works end to end.
test("the app serves the home route", async ({ page }) => {
const response = await page.goto("/");
expect(response?.status()).toBeLessThan(500);
await expect(page).toHaveTitle(/.+/);
});

64
package-lock.json generated
View file

@ -74,6 +74,7 @@
"zod": "^3.23.8"
},
"devDependencies": {
"@playwright/test": "1.61.1",
"@tailwindcss/forms": "^0.5.10",
"@testing-library/cypress": "^10.0.3",
"@types/node": "^24.10.1",
@ -3236,6 +3237,22 @@
"integrity": "sha512-8awtpHXCx/bNpFt4mt2xdkgtgVvKqty8VbjHI/WWWQuEw+KLzFot3f4+LkQY9YmOtq7A5GdOnqoIC8Pdygjk2g==",
"license": "MIT"
},
"node_modules/@playwright/test": {
"version": "1.61.1",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.61.1.tgz",
"integrity": "sha512-8nKv6+0RJSL9FE4jYOEGXnPeM/Hg12qZpmqzZjRh3qM0Y7c3z1mrOTfFLids72RDQYVh9WpLEfR5WdpNX4fkig==",
"devOptional": true,
"license": "Apache-2.0",
"dependencies": {
"playwright": "1.61.1"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@puppeteer/browsers": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/@puppeteer/browsers/-/browsers-2.3.0.tgz",
@ -12745,6 +12762,53 @@
"node": ">= 6"
}
},
"node_modules/playwright": {
"version": "1.61.1",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.1.tgz",
"integrity": "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==",
"devOptional": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.61.1"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.61.1",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.1.tgz",
"integrity": "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==",
"devOptional": true,
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/playwright/node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/possible-typed-array-names": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz",

View file

@ -11,6 +11,8 @@
"test:watch": "vitest",
"test:e2e:open": "start-server-and-test dev http://localhost:3000 \"cypress open --e2e\"",
"test:e2e:run": "cypress run",
"test:playwright": "playwright test",
"test:playwright:headed": "playwright test --headed",
"migration:generate": "drizzle-kit generate",
"migration:migrate": "drizzle-kit migrate",
"create_user": "tsx src/app/db/create_user.ts",
@ -86,6 +88,7 @@
"zod": "^3.23.8"
},
"devDependencies": {
"@playwright/test": "1.61.1",
"@tailwindcss/forms": "^0.5.10",
"@testing-library/cypress": "^10.0.3",
"@types/node": "^24.10.1",

33
playwright.config.ts Normal file
View file

@ -0,0 +1,33 @@
import { defineConfig, devices } from "@playwright/test";
// Cypress still owns the suites under cypress/e2e; Playwright specs live in
// e2e/ so the two don't collide.
export default defineConfig({
testDir: "./e2e",
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
reporter: process.env.CI ? "list" : "html",
use: {
baseURL: process.env.PLAYWRIGHT_BASE_URL ?? "http://localhost:3000",
trace: "on-first-retry",
screenshot: "only-on-failure",
},
// Chromium only: it's the single browser baked into the devcontainer image
// (see .devcontainer/Dockerfile). Adding firefox/webkit here means adding
// them there too, or runs fail with "Executable doesn't exist".
projects: [
{ name: "chromium", use: { ...devices["Desktop Chrome"] } },
],
// Boot `next dev` unless something is already serving the base URL, so a dev
// server you already have running gets reused rather than fought over.
webServer: {
command: "npm run dev",
url: "http://localhost:3000",
reuseExistingServer: !process.env.CI,
timeout: 120_000,
},
});

91
scripts/start_viewer.sh Executable file
View file

@ -0,0 +1,91 @@
#!/usr/bin/env bash
# Stand up a *viewable* desktop inside the dev container so you can watch and
# click a headed Playwright browser. Pipeline:
#
# Xvfb (:99 virtual screen) -> fluxbox (window manager)
# -> x11vnc (exposes :99 over VNC on 5900)
# -> websockify/noVNC (serves VNC in a browser tab on 6080)
#
# Then run Playwright headed with DISPLAY=:99 and the browser appears on that
# desktop:
#
# bash scripts/start_viewer.sh # start it
# DISPLAY=:99 npx playwright test --headed # watch it drive
# bash scripts/start_viewer.sh stop # tear it down
#
# Headless runs (the default) need none of this. Idempotent: re-running won't
# stack duplicate servers.
set -euo pipefail
DISPLAY_NUM=":99"
SCREEN="1600x1000x24"
VNC_PORT=5900
WEB_PORT=6080
RUN_DIR="/tmp/pw-viewer"
mkdir -p "$RUN_DIR"
_pids() { pgrep -f "$1" 2>/dev/null || true; }
stop() {
echo "Stopping viewer..."
for pat in "websockify.*$WEB_PORT" "x11vnc.*$DISPLAY_NUM" "fluxbox" "Xvfb $DISPLAY_NUM"; do
for pid in $(_pids "$pat"); do kill "$pid" 2>/dev/null || true; done
done
rm -f "/tmp/.X${DISPLAY_NUM#:}-lock" 2>/dev/null || true
echo "Stopped."
}
start() {
# Xvfb — the virtual screen everything draws into.
if [ -z "$(_pids "Xvfb $DISPLAY_NUM")" ]; then
Xvfb "$DISPLAY_NUM" -screen 0 "$SCREEN" -ac >"$RUN_DIR/xvfb.log" 2>&1 &
sleep 1
echo "Xvfb up on $DISPLAY_NUM ($SCREEN)"
else
echo "Xvfb already running on $DISPLAY_NUM"
fi
# fluxbox — a window manager, so the browser window is movable/resizable.
if [ -z "$(_pids "fluxbox")" ]; then
DISPLAY="$DISPLAY_NUM" fluxbox >"$RUN_DIR/fluxbox.log" 2>&1 &
sleep 1
echo "fluxbox window manager up"
fi
# x11vnc — exposes the screen over VNC. -forever keeps it alive across client
# reconnects; -nopw is fine because the port is only reachable through the
# container's forwarded port, not the public internet.
if [ -z "$(_pids "x11vnc.*$DISPLAY_NUM")" ]; then
x11vnc -display "$DISPLAY_NUM" -rfbport "$VNC_PORT" -forever -shared \
-nopw -quiet -bg -o "$RUN_DIR/x11vnc.log"
echo "x11vnc up on VNC port $VNC_PORT"
else
echo "x11vnc already running"
fi
# noVNC/websockify — serves the VNC stream as a web page on WEB_PORT.
if [ -z "$(_pids "websockify.*$WEB_PORT")" ]; then
websockify --web=/usr/share/novnc "$WEB_PORT" "localhost:$VNC_PORT" \
>"$RUN_DIR/websockify.log" 2>&1 &
sleep 1
echo "noVNC up on web port $WEB_PORT"
fi
cat <<EOF
Desktop ready.
1. Open the forwarded port $WEB_PORT in your browser:
http://localhost:$WEB_PORT/vnc.html?autoconnect=1&resize=remote
(In VS Code: PORTS tab -> open $WEB_PORT in browser.)
2. Run Playwright headed, pointing at this display:
DISPLAY=$DISPLAY_NUM npx playwright test --headed
The browser window appears on the desktop from step 1.
EOF
}
case "${1:-start}" in
start) start ;;
stop) stop ;;
restart) stop; start ;;
*) echo "usage: $0 [start|stop|restart]"; exit 2 ;;
esac