Next.js end-to-end tests
Needs: DockerA small Next.js app (App Router, plain JavaScript) with Playwright end-to-end tests, where installing, linting, building, testing and the dev server are all Lask tasks. The Playwright run on your laptop is the same one CI runs: same images, pinned by digest, same command.
Try it right now
You need Lask and Docker. No Node.js, no npm, no browsers:
git clone https://github.com/lask-task-runner/lask.git
cd lask/example/01-projects/04-nextjs-e2e
lask sync # one-time, needs network: fetches the tools module, pulls the images
lask run ci # install, lint and build, then the browser testsci installs the dependencies from package-lock.json, runs ESLint and next build side by side, then runs the e2e/ tests in Chromium and Firefox against the production build:
[#mcr.microsoft.com/playwright:v1.63.0-noble:4] 1| ✓ 2 [chromium] › e2e/todos.spec.js:14:1 › adds, completes and deletes a todo (336ms)
...
[#mcr.microsoft.com/playwright:v1.63.0-noble:4] 1| 8 passed (6.2s)node_modules/, .next/ and playwright-report/ land in this directory, as they would with a local Node; everything else stays in the containers, which are thrown away after each step.
To work on the app:
lask run dev # http://localhost:3000, reloads as you editTo watch the browser tests run:
lask run test-ui # http://localhost:9323, Playwright's UI modeIt installs and builds first, then serves Playwright's UI mode from the container. Pick a test and run it, in Chromium or Firefox, and step through every action with a snapshot of the page before and after. The browsers still have no window of their own, since the container has no display; what you see is the UI's record of each step, as it runs.
What the tasks look like
Everything lives in main.lask. The two environments come from lask-module-tools (declared in lask.json, pinned in lask.lock.json): Node for the npm steps, and Playwright's own image, which carries the browsers and the libraries they need:
command { "node", "npm", "npx" } on node
node_image = #node:24.22.1-bookworm-slim
node = tools.node.env(image = node_image, extra_env = quiet)
playwright = tools.playwright.env(
image = #mcr.microsoft.com/playwright:v1.63.0-noble,
extra_env = merge(quiet, {"CI": "1"})
)The Playwright image's tag must match the @playwright/test version in package.json (both 1.63.0): the image has that release's browsers and no other, and nothing downloads browsers at run time. Upgrade the two together. The Node image is Debian-based like Playwright's, so the native packages npm installs (Next's SWC compiler) load in both.
Most tasks are one line, and the npm in them picks the Node container:
install() = $ npm ci --no-audit --no-fund
lint() = $ npm run lint
build() = $ npm run buildThe browser tests name their environment explicitly, since npx is declared on Node. Playwright starts npm run start itself (playwright.config.js), inside the same container as the browsers, so the test is a single command:
test_e2e(--grep: String = "") =
grep == ""
? $[playwright] npx playwright test
: $[playwright] npx playwright test --grep #{grep}ci composes them. Lint and build don't depend on each other, so they run at the same time, in two containers:
ci() = do {
install()
checked = async lint()
built = async build()
await checked
await built
test_e2e()
}dev needs one thing the others don't: its port published to the host. Run options go on the environment, so the same image gets them for this task only:
internal dev_server(port: Number): Runnable =
runnable(node_image, env = quiet, publish = ["#{port}:3000"])
dev(--port: Number = 3000) = do {
log("open http://localhost:#{port}")
$[dev_server(port)] npm run dev
}lask run dev --port 8080 serves it on another port. The project directory is mounted into the container, and not every Docker setup passes file-change events across that mount, so next.config.mjs has the dev server poll for edits instead.
In CI
The pipeline is lask sync --frozen && lask run ci, so a CI job only installs Lask. On GitHub Actions, whose Ubuntu runners already have Docker:
jobs:
e2e:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install Lask
run: |
VERSION=$(curl -fsSL https://api.github.com/repos/lask-task-runner/lask/releases/latest | grep -m1 '"tag_name"' | cut -d '"' -f4)
curl -fsSL -o lask.tar.gz "https://github.com/lask-task-runner/lask/releases/download/${VERSION}/lask-${VERSION}-linux-amd64.tar.gz"
tar -xzf lask.tar.gz && sudo mv ./lask /usr/local/bin
- run: lask sync --frozen && lask run ci
working-directory: example/01-projects/04-nextjs-e2e
- uses: actions/upload-artifact@v4
if: failure()
with:
name: playwright-report
path: example/01-projects/04-nextjs-e2e/playwright-report--frozen makes sync fail rather than move the lock, so CI pulls exactly the image digests your laptop ran. Pin the Lask version too, instead of latest, once you depend on it.
Check it without running anything
lask check # names, arguments and types; no image is pulled
lask envs list # the images, and the digest each is pinned to
lask run --help # every task, from its own comments
lask run test-e2e --helpThe whole project is on GitHub: example/01-projects/04-nextjs-e2e.