A starter kit for getting your own smoke test up and running on Github, with Slack integration.
A complete, plug-and-play Playwright smoke test suite for Craft CMS, maintained against current Craft releases, with CI and deploy-platform integrations already debugged.
Point it at your site, edit one file, wire it to your deploy, and get pinged in Slack the moment something breaks. It answers the question you actually care about after every deploy: “Is the site up, rendering, and not throwing errors?” without you having to remember to check.
| Suite version | Craft versions | Playwright | Node |
|---|---|---|---|
| 1.x | 3.x, 4.x, 5.x, 6.x | ^1.x | 20+ |
The suite tests your site from the outside, so it is not version-locked to Craft.
smoke.config.js) drives every test. You should never need to touch the test files or playwright.config.js.| Test | What it guards against |
|---|---|
availability | 5xx, white screens, error text on critical URLs |
templates | “200 but the layout exploded” |
console | JS errors that break interactivity |
links | broken internal links |
seo | missing title / meta / canonical, unreachable robots & sitemap |
forms | contact/lead forms that silently stop submitting |
cp | control panel login down; optional authenticated check |
repository_dispatch) or on a schedule (cron).Spend less than 10 minutes to get to a passing smoke run against your homepage. Then grow it one check at a time.
node --version).npm install
npm run install:browsers # downloads Chromium for Playwright
Open smoke.config.js and change two lines:
baseUrl: 'https://your-live-site.com', // no trailing slash
siteName: 'Your Site Name',
Don’t want to edit yet? Run against any URL with an env var:
SMOKE_BASE_URL='https://your-live-site.com' npm test
npm test
Out of the box, only the checks that make sense with an empty config will run:
//// (+ robots.txt and sitemap)templates and forms are skipped until you configure them. You should see mostly green. If not, jump to Troubleshooting — the first run surfaces the usual suspects (wrong CP path, no sitemap, redirect on /).
npm run report
This opens the Playwright HTML report with screenshots for any failure.
Back in smoke.config.js:
criticalPaths: [
'/',
'/about',
'/services',
'/blog',
'/contact',
],
Run again. These now get the full availability + error-text treatment.
Confirm a page didn’t just return 200 but actually rendered its key elements:
templates: [
{ name: 'Homepage', path: '/', expect: ['header', 'main', 'footer'] },
{ name: 'Blog index', path: '/blog', expect: ['.post-card'] },
],
Once it’s green locally, make it run automatically:
ci/github-actions/smoke-on-deploy.yml into .github/workflows/.That’s it. Every deploy now smoke-tests itself and pings you only if something is wrong.
Everything you configure lives in smoke.config.js. This section documents every option. Options read from process.env can also be set in CI without editing the file.
| Option | Type | Default | Description |
|---|---|---|---|
baseUrl | string | https://example.com | Base URL under test, no trailing slash. Override with SMOKE_BASE_URL. |
siteName | string | My CraftQuest Site | Friendly name shown in reports and Slack. |
criticalPaths | string[] | ['/'] | Paths that must return 200 and render with no error text. |
errorText | string[] | Craft/Yii/PHP error strings | Case-insensitive substrings that, if found in HTML, fail the page. |
retries | number | 1 on CI, 0 local | Retries per test before reporting red. Smooths CDN races after deploy. |
errorTextThe defaults catch the common Craft/Twig/Yii/PDO fatal errors. Keep this list tight — an overly broad string (like error) will match legitimate content (“404 error page”) and cause false failures. Add strings specific to your stack if you have custom error output.
templates[]Each entry loads a page and asserts every selector is visible.
templates: [
{ name: 'Blog entry', path: '/blog/hello', expect: ['article h1', 'time', '.entry-body'] },
],
| Field | Type | Description |
|---|---|---|
name | string | Label in test output. |
path | string | Path to load, relative to baseUrl. |
expect | string[] | CSS selectors that must each be visible. Use stable, meaningful selectors (a heading, main content), not fragile ones. |
Leave the array empty to skip template tests.
consoleconsole: { paths: ['/'], ignore: ['favicon.ico'] },
| Field | Type | Description |
|---|---|---|
paths | string[] | Pages to load and watch for JS errors. |
ignore | string[] | Substrings of error messages to ignore (noisy third parties). |
A test fails if any console.error or uncaught exception fires that isn’t ignored. Pages are loaded to networkidle so late errors are caught.
linkslinks: { paths: ['/'], checkExternal: false, ignore: ['mailto:', 'tel:', '#'] },
| Field | Type | Description |
|---|---|---|
paths | string[] | Pages whose links are collected and checked. |
checkExternal | boolean | If true, also check links to other hosts. Off by default (external sites cause flaky failures). |
ignore | string[] | Href substrings to skip entirely. |
Each link is checked with HEAD (falling back to GET if the server rejects HEAD). A status ≥ 400 fails the test.
seoseo: {
paths: ['/'],
requireCanonical: true,
requireMetaDescription: true,
robotsPath: '/robots.txt',
sitemapPath: '/sitemap.xml',
},
| Field | Type | Description |
|---|---|---|
paths | string[] | Pages checked for <title>, meta description, canonical. |
requireCanonical | boolean | Require a non-empty <link rel="canonical">. |
requireMetaDescription | boolean | Require a non-empty meta description. |
robotsPath | string | Path to robots.txt; must be reachable. |
sitemapPath | string | Path to the sitemap; must be reachable. If you use the SEOmatic/sitemap plugin, set this to its URL (e.g. /sitemaps-1-sitemap.xml). |
forms[]forms: [{
name: 'Contact form',
path: '/contact',
formSelector: 'form#contact',
fields: { fromName: 'Smoke Test', fromEmail: '[email protected]', message: 'Please ignore.' },
honeypot: 'freeform_form_handle',
submitSelector: 'button[type="submit"]',
successText: 'Thanks',
}],
| Field | Type | Description |
|---|---|---|
name | string | Label in test output. |
path | string | Page containing the form. |
formSelector | string | CSS selector for the <form>. |
fields | object | { inputName: value } pairs to fill. |
honeypot | string | Name of the hidden anti-spam field; left blank so the form treats you as human. |
submitSelector | string | Selector for the submit button (within the form). |
successText | string | Text that appears only on success. |
Point form tests at staging or use forms that tolerate test submissions — a passing test means a real email/entry was created.
cpcp: {
loginPath: '/admin/login',
dashboardPath: '/admin/dashboard',
username: process.env.SMOKE_CP_USERNAME || null,
password: process.env.SMOKE_CP_PASSWORD || null,
},
| Field | Type | Description |
|---|---|---|
loginPath | string | Craft CP login path. Match your cpTrigger (default admin). |
dashboardPath | string | Authenticated landing page to verify. |
username / password | string / null | From env only. If both set, the authenticated check runs; otherwise it’s skipped. |
Never commit credentials. Set SMOKE_CP_USERNAME / SMOKE_CP_PASSWORD as CI secrets.
timeoutstimeouts: { action: 15000, test: 30000 },
| Field | Type | Description |
|---|---|---|
action | ms | Per-action / navigation timeout. Raise for slow origins. |
test | ms | Per-test timeout. |
notifications.slacknotifications: {
slack: {
webhookUrl: process.env.SMOKE_SLACK_WEBHOOK || null,
notifyOn: 'failure',
},
},
| Field | Type | Description |
|---|---|---|
webhookUrl | string / null | Slack incoming webhook. null disables notifications. From env. |
notifyOn | 'failure' | 'always' | When to post. See Slack notifications. |
| Var | Purpose |
|---|---|
SMOKE_BASE_URL | Override baseUrl at runtime (per-environment CI). |
SMOKE_SLACK_WEBHOOK | Slack webhook URL. |
SMOKE_CP_USERNAME / SMOKE_CP_PASSWORD | Optional authenticated CP check. |
CI | Set by CI; enables retries and forbids test.only. |
Once it’s green locally, run it automatically after every deploy. The pattern is identical on every platform: after a successful deploy, POST a repository_dispatch event to GitHub, which the smoke-on-deploy.yml workflow listens for.
First, copy the workflow you want into .github/workflows/:
ci/github-actions/smoke-on-deploy.yml — run after each deployci/github-actions/smoke-scheduled.yml — run on a cron (uptime monitoring)Then pick your platform below.
Create a fine-grained personal access token (or a GitHub App token) with:
contents:write-equivalent repository_dispatch scope; a classic token needs the repo scope).Store it wherever your deploy platform keeps secrets, typically as SMOKE_DISPATCH_TOKEN. A successful dispatch returns HTTP 204 with no body.
Tip: run the curl locally first with your token to confirm a
204 No Contentresponse before wiring it into your platform.
Servd runs post-deploy commands via the In-App Commands / project shell.
SMOKE_DISPATCH_TOKEN.curl -sS -X POST \
-H "Accept: application/vnd.github+json" \
-H "Authorization: Bearer $SMOKE_DISPATCH_TOKEN" \
https://api.github.com/repos/YOUR_ORG/craftquest-smoke-suite/dispatches \
-d '{"event_type":"site-deployed","client_payload":{"base_url":"https://your-live-site.com"}}'
Replace YOUR_ORG/craftquest-smoke-suite and base_url. Deploy once; within a few seconds the Smoke — on deploy workflow should appear in the repo’s Actions tab.
SMOKE_DISPATCH_TOKEN, marked as encrypted/secret.curl -sS -X POST \
-H "Accept: application/vnd.github+json" \
-H "Authorization: Bearer $SMOKE_DISPATCH_TOKEN" \
https://api.github.com/repos/YOUR_ORG/craftquest-smoke-suite/dispatches \
-d '{"event_type":"site-deployed","client_payload":{"base_url":"'"$SMOKE_BASE_URL"'"}}'
Set SMOKE_BASE_URL as a pipeline variable so the same pipeline can target different environments. In the action’s Run settings, choose Run this action only if all previous actions passed so a failed deploy doesn’t trigger a false smoke run.
Forge deploys Craft sites via its Deploy Script. Because Forge halts the deploy script on the first failing command, the curl is only reached when the deploy itself succeeded.
SMOKE_DISPATCH_TOKEN=ghp_xxx and SMOKE_BASE_URL=https://your-site.com.# Kick off external smoke tests — only reached if the deploy above succeeded.
curl -sS -X POST \
-H "Accept: application/vnd.github+json" \
-H "Authorization: Bearer $SMOKE_DISPATCH_TOKEN" \
https://api.github.com/repos/YOUR_ORG/craftquest-smoke-suite/dispatches \
-d "{\"event_type\":\"site-deployed\",\"client_payload\":{\"base_url\":\"$SMOKE_BASE_URL\"}}"
DeployHQ can run SSH commands or fire a webhook after a successful deployment.
Option A — Post-deploy SSH command (recommended). In DeployHQ: Project → Settings → SSH Commands, add a command that runs After Changes are Applied:
curl -sS -X POST \
-H "Accept: application/vnd.github+json" \
-H "Authorization: Bearer YOUR_GITHUB_TOKEN" \
https://api.github.com/repos/YOUR_ORG/craftquest-smoke-suite/dispatches \
-d '{"event_type":"site-deployed","client_payload":{"base_url":"https://your-live-site.com"}}'
DeployHQ SSH commands do not expand your server’s env vars reliably, so it is simplest to inline the token and URL here. Restrict the token’s scope accordingly, and rotate it if the project is shared.
Option B — Webhook. DeployHQ’s built-in webhook posts its own JSON payload, which the GitHub repository_dispatch API will reject (wrong shape). If you prefer webhooks, point DeployHQ at a tiny relay (see the custom bash pattern below) that reshapes the payload. For most members, Option A is fewer moving parts.
If your deploy is “SSH in and run a script”, or any platform not covered above, put this at the end of your deploy script and use set -e so it is only reached when every prior step succeeded:
#!/usr/bin/env bash
set -euo pipefail
# ... your existing deploy steps (composer install, craft up, etc.) ...
# Only reached if everything above succeeded:
curl -sS -X POST \
-H "Accept: application/vnd.github+json" \
-H "Authorization: Bearer $SMOKE_DISPATCH_TOKEN" \
https://api.github.com/repos/YOUR_ORG/craftquest-smoke-suite/dispatches \
-d '{"event_type":"site-deployed","client_payload":{"base_url":"https://your-live-site.com"}}'
If one script deploys multiple environments, pass the URL through:
BASE_URL="$1" # e.g. ./deploy.sh https://staging.example.com
curl -sS -X POST \
-H "Authorization: Bearer $SMOKE_DISPATCH_TOKEN" \
https://api.github.com/repos/YOUR_ORG/craftquest-smoke-suite/dispatches \
-d "{\"event_type\":\"site-deployed\",\"client_payload\":{\"base_url\":\"$BASE_URL\"}}"
The workflow reads client_payload.base_url into SMOKE_BASE_URL, overriding smoke.config.js.
204 is your problem — the body of a 401/422 explains it.event_type matches types: [site-deployed] in smoke-on-deploy.yml.repository_dispatch only triggers workflows on the repo’s default branch. Make sure the workflow file is merged to main.owner/repo, and that it points at the smoke suite repo — not your site’s repo.Get pinged in Slack the moment a smoke run fails.
Prerequisite: you need permission to create an app in your Slack workspace. If your workspace restricts this, ask a workspace admin to either grant it or create the webhook and hand you the URL. Incoming Webhooks are a free, built-in Slack feature — there is nothing to install or pay for.
#deploys), and Allow.https://hooks.slack.com/services/T000/B000/XXXX.Never commit the webhook. Add it to GitHub:
SMOKE_SLACK_WEBHOOKOr from the command line (the value is entered interactively, so it never lands in your shell history):
gh secret set SMOKE_SLACK_WEBHOOK
The workflows already reference secrets.SMOKE_SLACK_WEBHOOK, and smoke.config.js reads process.env.SMOKE_SLACK_WEBHOOK.
In smoke.config.js:
notifications: {
slack: {
webhookUrl: process.env.SMOKE_SLACK_WEBHOOK || null,
notifyOn: 'failure', // 'failure' (default) or 'always'
},
},
'failure' — only ping when the suite goes red (recommended).'always' — ping on every run, green or red (useful while dialing in).SMOKE_SLACK_WEBHOOK='https://hooks.slack.com/services/...' \
node ci/notify.js --status failure
You should see a message land in your channel. If not:
403/404 from Slack means the webhook is wrong or was revoked.notify: no Slack webhook configured, skipping. means the env var was empty.🔴 Smoke suite failure — My CraftQuest Site
URL: https://your-live-site.com
Passed: 6 · Failed: 1 · Flaky: 0 · Skipped: 2
View run
The summary line comes from Playwright’s results.json; the View run link points at the GitHub Actions run when available.
The built-in tests cover the “is it up and rendering” basics for any Craft site. Sooner or later you’ll want to assert something specific to your site — that the cart shows a total, that a gated page redirects, that a search returns results. This is how.
Config-only (preferred when it fits): add entries to templates or forms in smoke.config.js. No JavaScript required. Covers “this page renders these elements” and “this form submits”.
A new spec file: drop a *.spec.js into tests/. Playwright picks it up automatically. Use this for behavior the config can’t express.
const { test, expect } = require('@playwright/test');
const smoke = require('../smoke.config');
test('cart shows a running total', async ({ page }) => {
await page.goto('/shop/cart');
const total = page.locator('[data-cart-total]');
await expect(total).toBeVisible();
await expect(total).toContainText('$');
});
Key points:
baseURL is already set from smoke.config.js, so page.goto('/shop/cart') resolves against your site. Use relative paths.smoke if you want to reuse config values (base URL, credentials, site-specific lists you add).data-* hooks in your templates (data-cart-total) rather than depending on class names that change with CSS.test('members-only page redirects anonymous visitors', async ({ page }) => {
const res = await page.goto('/members/dashboard');
expect(page.url()).toContain('/login');
});
test('site search finds a known entry', async ({ page }) => {
await page.goto('/search?q=craft');
await expect(page.locator('.search-result').first()).toBeVisible();
});
test('menu JSON endpoint returns items', async ({ request }) => {
const res = await request.get('/actions/menu/get');
expect(res.status()).toBeLessThan(400);
const body = await res.json();
expect(Array.isArray(body.items)).toBe(true);
});
Reuse the CP credentials pattern: read from env, skip when absent.
const hasCreds = smoke.cp.username && smoke.cp.password;
(hasCreds ? test : test.skip)('logged-in user sees their name', async ({ page }) => {
// ... perform login, then assert ...
});
When a new version of the Suite ships, you’ll typically replace the stock spec files. To avoid losing your work:
tests/site-checkout.spec.js, tests/site-membership.spec.js. Never edit the stock availability.spec.js etc. — add alongside them.smoke.config.js (you own that file; upgrades won’t overwrite your values, only the shape — the changelog notes any config changes).npx playwright test tests/site-checkout.spec.js
npx playwright test -g "cart shows" # by test title
npm run test:ui # interactive runner, great for authoring
Smoke tests should be deterministic. If a test only passes sometimes:
expect(locator).toBeVisible() (auto-waits), not fixed timeouts.waitUntil: 'networkidle' for JS-heavy pages.The failures everyone hits, in roughly the order people hit them. Each has the symptom, the cause, and the fix.
availability: / returned 301/302Symptom: the homepage “fails” with a redirect status. Cause: your baseUrl redirects (http→https, non-www→www, or a trailing slash rule). Fix: set baseUrl to the canonical URL your site actually serves — the one with no redirect. Check with curl -I https://your-site.com. If the final URL is https://www.your-site.com/, use that.
cp: login page loads fails with 404Symptom: the control panel test can’t find the login page. Cause: your cpTrigger isn’t admin, or the CP is on a subdomain. Fix: set cp.loginPath to match your Craft config. If cpTrigger is cp, use /cp/login. If the CP is on cms.your-site.com, either run a separate config against that host or set SMOKE_BASE_URL for the CP run.
seo: sitemap is reachable failsSymptom: sitemap returns 404. Cause: Craft has no sitemap by default — it comes from a plugin (SEOmatic, craft-sitemap, etc.), each with its own URL. Fix: set seo.sitemapPath to your plugin’s actual sitemap URL (SEOmatic is often /sitemaps-1-sitemap.xml). No sitemap plugin? Remove the check by pointing it at a URL you know 200s, or accept the skip.
console: / has no JS errors fails on third-party noiseSymptom: failures from Google Maps, analytics, chat widgets, or favicon.ico. Cause: third-party scripts log errors you don’t control. Fix: add a substring to console.ignore in smoke.config.js. Keep it specific ('Google Maps JavaScript API'), not broad. Don’t ignore errors coming from your own JS — fix those.
links fails on links that work in a browserSymptom: a link reports 403 or 405 but loads fine when you click it. Cause: some servers/CDNs block HEAD requests or bot-like user agents, or the link requires a session. Fix: the test already falls back from HEAD to GET on 405⁄501. For 403 from a CDN, add the link (or a path fragment) to links.ignore. For auth-gated links, ignore them — smoke tests run anonymously.
Symptom: running the suite floods an inbox or CRM. Cause: the form test genuinely submits the form. Fix: point forms[].path at staging, or use a form that routes test submissions somewhere harmless. Make successText match a string that appears only on success so a validation error doesn’t read as a pass.
Symptom: green locally, red in the on-deploy run, green if you re-run. Cause: the CDN/edge cache is still warming, or the deploy platform fired the trigger before the new release was live. Fix: retries is already 1 on CI to absorb this. If it persists, add a short delay before the dispatch in your deploy trigger, or increase timeouts.action. Confirm your platform triggers after the release is promoted, not at build time.
Executable doesn't exist / browser not installedSymptom: Playwright complains the Chromium binary is missing. Cause: browsers weren’t installed. Fix: run npm run install:browsers. On CI the workflow already does this; if you customized it, ensure playwright install --with-deps chromium runs before npm test.
Symptom: runs go red but no Slack message. Causes & fixes:
notify: no Slack webhook configured — the SMOKE_SLACK_WEBHOOK secret isn’t set in the repo. Add it (see Slack notifications).notifyOn is 'failure' — set it to 'always'.if: failure() notify step and that npm test actually failed (a skipped suite is not a failure).repository_dispatch fires but no workflow runsSymptom: your deploy curl returns 204 but nothing shows in Actions. Causes & fixes:
repository_dispatch to trigger it. Merge it to main.event_type in your curl must match types: [site-deployed] in the workflow.See Supported configurations & maintenance for how to report a break. Include the failing test name, the full error output, your smoke.config.js (redact secrets), your Node version, and whether it’s local or CI.
The maintenance is the product. This section defines it precisely — what is committed, what is deliberately not, and which configurations are in scope.
fetch).These are roadmap candidates, not commitments: multi-site installs, headless / decoupled front ends, GitLab CI / Bitbucket Pipelines, and SSO or custom control-panel authentication.
The suite follows semver, decoupled from Craft’s version numbers — there is no “Smoke Suite 5.x for Craft 5”. The compatibility matrix at the top communicates Craft support instead.
smoke.config.js format, or required Node/Playwright version bumps.Members don’t have repo access, so there are no GitHub Issues. Use the support form on the suite’s member page and pick the right category — bug in the suite, compatibility report, or roadmap request. Reported bugs that get fixed become entries on the public changelog.
Include: the failing test name, the full error output, your smoke.config.js (redact secrets), your Node version, and whether it’s local or CI. The common first-wave failures are already answered in Troubleshooting — check there first.
This suite is provided under the member license: active members may use it on unlimited projects including client work; no redistribution or resale; code already deployed to client repos keeps working, but updates require an active membership.