Instalar com Codex ou Claude Copie este prompt, cole no Codex, Claude ou outro assistente e deixe que ele revise a página da skill e instale para você.
Um comando direto ignora o prompt de revisão. Verifique a origem antes de executá-lo.
Elgg has no built-in JS test framework. Testing is entirely PHP-based.
Plugin JS must bring its own test setup. This skill provides that.
JS Module System Per Version
Elgg
System
File Extension
Load API
2.x-5.x
RequireJS/AMD
.js
elgg_require_js(), define(), require()
6.x
Native ES Modules
.mjs
elgg_import_esm(), import/export
7.x
Native ES Modules (importmap)
.mjs
elgg_import_esm(), import/export
7.x JS gotchas (still ESM, but tighter — cover these in tests): jQuery is a
DEFERRED ESM module, no longer a global — import $ from 'jquery' and expose
window.jQuery/$ before dependent code (bare jQuery(/window.jQuery throws).
elgg/i18n has a DEFAULT export only — import i18n from 'elgg/i18n', not
import { echo }. ESM importmap specifiers are the full view path minus .mjs
(no js/ strip); an unmapped specifier throws "Failed to resolve module
specifier". A Playwright/console assertion of "no pageerror, no unresolved
specifier" per page is the 7.x front-end gate.
Skill layout (no shared docker stack)
This skill does not ship its own docker infrastructure. It assumes the
plugin under test already has a per-plugin test stack at
<plugin>/docker/docker-compose.yml — scaffolded by the
elgg-test-writer skill (which copies templates into the plugin repo).
Every docker command below is run from the plugin root and
references docker/docker-compose.yml relative to that root. If the
plugin does not yet have a docker/ directory, run the deterministic
bootstrap script from the elgg-test-writer skill first:
<path-to-elgg-test-writer>/bin/scaffold-docker.sh
The script infers PLUGIN_ID and the Elgg major version from the
plugin's composer.json and writes the full docker stack under
<plugin>/docker/. See the elgg-test-writer SKILL.md for details.
Each plugin ends up with its own isolated stack (own containers,
volumes, network, and ports scoped to ${PLUGIN_ID}-elgg{N}) — nothing
is shared between plugins, and this skill never touches anything
outside the plugin repository.
Container Infrastructure
All JS test operations run inside Docker containers via the node service.
# Run Vitest (JS unit tests)
docker compose -f docker/docker-compose.yml --profile test run --rm node sh -c \
"cd /plugin && npm ci && npm run test:js"# Run Vitest in watch mode
docker compose -f docker/docker-compose.yml --profile test run --rm node sh -c \
"cd /plugin && npm ci && npm run test:js:watch"# Interactive shell for debugging
docker compose -f docker/docker-compose.yml --profile test run --rm node bash
# Combined with Playwright (browser-level)
docker compose -f docker/docker-compose.yml --profile test run --rm node sh -c \
"cd /plugin/tests/playwright && npm ci && npx playwright test"
npm cirequires a committed package-lock.json — it exits with ENOENT when
there is none. Copy both files from this skill before the first run, and commit them:
@playwright/test is pinned to an exact 1.49.0 — not^1.x. It must match the
browser build baked into the node service image
(mcr.microsoft.com/playwright:v1.49.0-noble); a floating range silently installs a
client newer than the browsers in the image. If you bump one, bump the other.
The node service uses the official Playwright Docker image (includes Node.js 20).
The plugin's source directory is mounted at /plugin inside the node container via the per-plugin docker/docker-compose.yml.
Phase 1: SCAN PLUGIN FOR TEST TARGETS
Read three layers together — views, CSS, and JS — to understand what behaviors to test.
Two things in that file are load-bearing, and getting either wrong produces a
"Failed to resolve import" that looks like a missing mock:
Aliases use the ARRAY form, most specific first. Vite/rollup alias matching
is prefix-based (id === find || id.startsWith(find + '/')) and takes the first
match. An object with a bare 'elgg' key ahead of 'elgg/Ajax' rewrites
elgg/Ajax to <elgg mock>/Ajax, and the import dies. The bare module is
matched with /^elgg$/ so it can never swallow a submodule.
Replacements are absolute (fileURLToPath(new URL(...))). A relative
'./tests/js/mocks/x.mjs' is resolved against the importer, not the project
root, and resolves only sometimes.
// Mock Elgg hooks module (6.x)const handlers = newMap();
exportfunctionregister(name, type, handler, priority = 500) {
const key = `${name}:${type}`;
if (!handlers.has(key)) handlers.set(key, []);
handlers.get(key).push({ handler, priority });
}
exportfunctiontrigger(name, type, params, value) {
const key = `${name}:${type}`;
const list = handlers.get(key) || [];
list.sort((a, b) => a.priority - b.priority);
for (const { handler } of list) {
const result = handler(name, type, params, value);
if (result !== undefined) value = result;
}
return value;
}
exportfunctionreset() {
handlers.clear();
}
tests/js/mocks/i18n.mjs
Shipped at templates/js/mocks/i18n.mjs.
On 7.x the real elgg/i18n has a default export only, so a module under test
doing import i18n from 'elgg/i18n' receives undefined from a mock that exports
only named bindings — and every call on it throws. The shipped mock exports a
default, and keeps the named exports alongside it so it still serves 6.x modules
written as import { echo } from 'elgg/i18n'.
tests/js/mocks/jquery.mjs
Shipped at templates/js/mocks/jquery.mjs. Requires the real library:
npm i -D jquery
Do not hand-roll a jQuery mock for a module that uses jQuery. A $ built from
document.querySelector returns a bare DOM node, so the first .on(), .each(),
.addClass() or .data() the module under test calls throws — which is nearly all
real plugin JS. The shipped mock binds the real library to the jsdom window and
exposes window.$ / window.jQuery, which 7.x no longer does automatically.
Phase 4: WRITE TESTS
Test categories
Pure Logic Tests (no DOM)
For utility functions, data transformers, validators:
docker compose -f docker/docker-compose.yml --profile test run --rm node sh -c \
"cd /plugin && npm ci && npm run test:js"
In CI (GitHub Actions)
Run the SAME Docker command CI uses locally, so green-CI == green-local (the
plain vitest unit tests don't need Elgg, but running them through the compose
node service keeps one execution path and matches Playwright's networked run).
Add to .github/workflows/tests.yml:
js-tests:runs-on:ubuntu-lateststeps:-uses:actions/checkout@v4-run:>
docker compose -f docker/docker-compose.yml --profile test run --rm node
sh -c "cd /plugin && npm ci && npm run test:js"
Combined with Playwright (browser-level)
# Start Elgg in Docker
docker compose -f docker/docker-compose.yml up -d
# Run Playwright tests inside Docker (shares network with Elgg + DB)
docker compose -f docker/docker-compose.yml --profile test run --rm node sh -c \
"cd /plugin/tests/playwright && npm ci && npx playwright test"
Decision guide — Vitest vs Playwright:
Use Vitest for
Use Playwright for
Pure functions, data transforms
AJAX form submissions hitting real Elgg actions
Hook registration/triggering
CSS state class transitions after user interaction
import { defineConfig } from'@playwright/test';
exportdefaultdefineConfig({
use: {
// The Elgg container listens on port 80 inside the compose network — use the// bare service hostname, NOT :8080 (a wrong port here = connection refused).// baseURL MUST live inside use:{} (a root-level baseURL is silently ignored// in Playwright >=1.50). The scaffold-docker.sh stack names the service `elgg`// and sets ELGG_SITE_URL=http://elgg/ so post-login redirects resolve.baseURL: 'http://elgg',
screenshot: 'only-on-failure',
video: 'retain-on-failure',
},
testDir: './tests',
timeout: 30_000,
});
Pattern 0: Console / pageerror smoke gate (MANDATORY for every page type)
This is the single most important JS test and the one most often missing or inert.
HTTP-status gates (curl, render golden-master) return 200 even when JS throws —
an Elgg 7 importmap specifier that fails to resolve, or a jQuery is not defined,
aborts the module in the browser while the page still serves. A smoke spec that only
asserts the page loaded (or that never reads the console) catches none of it.
Every page type (home, listing, profile, profile-edit, group, admin, each form) gets
one spec that captures bothconsole errors and uncaught pageerror, waits for
deferred modules to run, and asserts the real-error set is empty against an explicit
allow-list of known-benign external noise.
// tests/playwright/tests/smoke.spec.tsimport { test, expect } from'@playwright/test';
// Known-benign noise NOT caused by the migration (external tags, PWA-manifest-under-// basic-auth, blocked third-party requests). Keep this list short and justified.constIGNORE = [
/www\.googletagmanager\.com/, /web-share/, /www-widgetapi/, /youtube/,
/1Password/, /ERR_BLOCKED_BY_CLIENT/, /manifest\.json.*40[13]/, /net::ERR_/,
];
for (const path of ['/', '/activity', '/members', '/groups/all', '/blog/all']) {
test(`no console/page errors on ${path}`, async ({ page }) => {
consterrors: string[] = [];
page.on('console', (m) => { if (m.type() === 'error') errors.push(m.text()); });
page.on('pageerror', (e) => errors.push(String(e))); // uncaught throws / module-resolutionawait page.(path);
page.();
real = errors.( !.( re.(e)));
(real, ).([]);
});
}
Gotchas that make this gate silently pass when it shouldn't:
Forgetting pageerror. "Failed to resolve module specifier …" arrives as a
pageerror, not always a console message. Capture both.
No wait. ES modules are deferred; assert after a waitForTimeout/waitForLoadState
or the page reports clean before any module ran.
Over-broad allow-list. A regex like /.*/ or /elgg/ neuters the gate. Each
entry must name a specific external/benign source.
Walled-garden sites 404 most content anonymously — log in (helper below) so the
authenticated page types are actually exercised.
Pattern A: State class transitions
When JS adds/removes CSS classes in response to events, test the class directly.
// tests/playwright/tests/validation.spec.tsimport { test, expect } from'@playwright/test';
import { loginAsAdmin } from'../helpers/login';
test('shows inline error when required field is empty', async ({ page }) => {
awaitloginAsAdmin(page);
await page.goto('/path/to/form-page');
// Submit without filling required fieldawait page.click('[type=submit]');
// Field row should have error classconst field = page.locator('.elgg-field').filter({ has: page.locator('[name="title"]') });
awaitexpect(field).toHaveClass(/elgg-field-has-errors/);
// Error message list should be visibleawaitexpect(field.locator('.elgg-field-feedback')).toBeVisible();
awaitexpect(field.locator('.elgg-field-feedback li')).toContainText(['required']);
});
test('clears error class when field is corrected', async ({ page }) => {
(page);
page.();
page.();
field = page.().({ : page.() });
(field).();
page.(, );
page.().();
(field)..();
});
Pattern B: AJAX form submission lifecycle
For plugins that intercept form submit with JS (e.g. hypeajax-style forms):
Every AJAX action in Elgg surfaces feedback through elgg.system_message() / elgg.register_error(). Test these for any action-based workflow:
// Check for success system messageawaitexpect(page.locator('.elgg-system-messages .elgg-message-success')).toBeVisible({ timeout: 5_000 });
// Check for error system messageawaitexpect(page.locator('.elgg-system-messages .elgg-message-error')).toBeVisible({ timeout: 5_000 });
Playwright test file structure
<plugin>/
tests/
playwright/
playwright.config.ts
package.json # copy templates/playwright/package.json
package-lock.json # copy templates/playwright/package-lock.json — COMMIT IT
helpers/
login.ts # loginAsAdmin(), loginAsUser()
fixtures/
test-image.jpg # Small test file for upload tests
test-doc.pdf
tests/
validation.spec.ts # Form validation state classes
ajax-form.spec.ts # AJAX submit lifecycle
file-upload.spec.ts # Dropzone/upload flows
toggle.spec.ts # Show/hide UI panels
permissions.spec.ts # Guest vs owner visibility
Playwright test coverage checklist
Every CSS state class that JS toggles is tested (success, error, loading, active, hidden)
Every AJAX action has: pending state, success path, error path
Every toggler has: initial state, after click, after reset