一键导入
elgg-js-test-writer
Use when writing JavaScript tests for Elgg plugins, testing AMD or ES modules, or setting up Vitest/Playwright for Elgg JS code.
用 Codex 或 Claude 帮你安装 复制这段 Prompt,粘贴到 Codex、Claude 或其他助手里,让它检查 Skill 页面并帮你完成安装。
菜单
Use when writing JavaScript tests for Elgg plugins, testing AMD or ES modules, or setting up Vitest/Playwright for Elgg JS code.
用 Codex 或 Claude 帮你安装 复制这段 Prompt,粘贴到 Codex、Claude 或其他助手里,让它检查 Skill 页面并帮你完成安装。
基于 SOC 职业分类
Use when migrating Elgg CMS plugins between major versions (2.x→3.x, 3.x→4.x, etc.), upgrading plugin APIs, or modernizing legacy Elgg code. Triggers on "migrate elgg", "upgrade plugin", "elgg breaking changes".
Use when writing PHPUnit tests for Elgg plugins, generating test suites, or adapting tests between Elgg versions. Triggers on "test elgg plugin", "write elgg tests", "elgg integration test".
Use when upgrading an entire Elgg installation (core + all plugins) between major versions, or running a production upgrade with backup and rollback.
| name | elgg-js-test-writer |
| description | Use when writing JavaScript tests for Elgg plugins, testing AMD or ES modules, or setting up Vitest/Playwright for Elgg JS code. |
Purpose: Write JavaScript tests for Elgg plugin modules. Usage:
/elgg-js-test-writer <plugin-path> [--elgg-version=6.x]
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.
| 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 |
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.
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"
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.
Read three layers together — views, CSS, and JS — to understand what behaviors to test.
# Find all JS/MJS files
find <plugin>/views -name "*.js" -o -name "*.mjs" | sort
# AMD modules (define/require pattern)
grep -rl "define(\|require(\[" <plugin>/views --include="*.js"
# ES modules (import/export)
grep -rl "^import \|^export " <plugin>/views --include="*.mjs"
# Inline scripts in PHP views
grep -rl "<script" <plugin>/views --include="*.php"
For each JS file, note:
find <plugin>/views -name "*.css" -o -name "*.less" | sort
For each stylesheet, extract:
has-errors, success, loading, hidden, active)[data-dz-*], [data-src])These classes are the observable test signals in Playwright.
find <plugin>/views/default -name "*.php" | sort
For each view file, identify:
<script> tags bootstrap JS moduleselgg_view_form, elgg_view_menu, elgg_view_field, elgg_view_module)Before writing any test, produce a table:
| File | What it does | Observable signals |
|---|---|---|
views/default/myplugin/form.php | Renders upload form with data-max-size | Form element with data-max-size attr |
views/default/myplugin/upload.js | Initializes Dropzone on .elgg-dropzone | .elgg-dropzone-success class after upload |
views/default/myplugin/styles.css | .has-errors turns label orange | Label color computed style |
This map drives Phase 4 (Vitest) and Phase 5 (Playwright) test targets.
docker compose -f docker/docker-compose.yml --profile test run --rm node sh -c \
"cd /plugin && npm init -y && npm install -D vitest jsdom"
vitest.config.ts:
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
environment: 'jsdom',
include: ['tests/js/**/*.test.{ts,js,mjs}'],
globals: true,
},
resolve: {
alias: {
// Mock Elgg core modules that won't be available in test env
'elgg': './tests/js/mocks/elgg.mjs',
'elgg/Ajax': './tests/js/mocks/Ajax.mjs',
'elgg/hooks': './tests/js/mocks/hooks.mjs',
'elgg/i18n': './tests/js/mocks/i18n.mjs',
'jquery': './tests/js/mocks/jquery.mjs',
},
},
});
package.json scripts:
{
"scripts": {
"test:js": "vitest run",
"test:js:watch": "vitest"
}
}
AMD modules need a shim since Vitest runs ES modules natively:
docker compose -f docker/docker-compose.yml --profile test run --rm node sh -c \
"cd /plugin && npm install -D vitest jsdom"
tests/js/mocks/amd-shim.mjs:
// Minimal AMD define/require shim for testing
const modules = new Map();
export function define(name, deps, factory) {
if (typeof name !== 'string') {
factory = deps;
deps = name;
name = null;
}
if (!Array.isArray(deps)) {
factory = deps;
deps = [];
}
const resolved = deps.map(d => modules.get(d));
const result = factory(...resolved);
if (name) modules.set(name, result);
return result;
}
export function require(deps, callback) {
const resolved = deps.map(d => modules.get(d));
return callback(...resolved);
}
// Pre-register core modules
modules.set('jquery', await import('./jquery.mjs').then(m => m.default));
modules.set('elgg', await import('./elgg.mjs').then(m => m.default));
Elgg JS modules depend on the Elgg runtime. Mock the essentials:
// Mock Elgg core module
export default {
get_site_url: () => 'http://localhost:8380/',
get_logged_in_user_guid: () => 1,
echo: (key) => key,
trigger_hook: (name, type, params, value) => value,
register_hook_handler: () => {},
security: {
addToken: (data) => ({ ...data, __elgg_ts: '123', __elgg_token: 'abc' }),
},
session: {
cookie: (name) => null,
},
config: {
lastcache: Date.now(),
},
};
// Mock Elgg Ajax module
export default class Ajax {
constructor() {}
async action(name, options = {}) {
return { value: null, ...options.data };
}
async path(path, options = {}) {
return {};
}
async view(view, options = {}) {
return '<div>mock view</div>';
}
async form(action, options = {}) {
return '<form>mock form</form>';
}
}
// Mock Elgg hooks module (6.x)
const handlers = new Map();
export function register(name, type, handler, priority = 500) {
const key = `${name}:${type}`;
if (!handlers.has(key)) handlers.set(key, []);
handlers.get(key).push({ handler, priority });
}
export function trigger(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;
}
export function reset() {
handlers.clear();
}
// Mock Elgg i18n module
const translations = {};
export function echo(key, args = []) {
let str = translations[key] || key;
args.forEach((arg, i) => {
str = str.replace(`%s`, arg);
});
return str;
}
export function addTranslation(lang, strings) {
Object.assign(translations, strings);
}
// Use jsdom's built-in document or a minimal jQuery mock
import { JSDOM } from 'jsdom';
const dom = new JSDOM('<!DOCTYPE html><html><body></body></html>');
const $ = (selector) => dom.window.document.querySelector(selector);
$.fn = {};
$.ajax = async () => ({});
$.extend = Object.assign;
export default $;
For utility functions, data transformers, validators:
// tests/js/utils.test.mjs
import { describe, it, expect } from 'vitest';
import { formatDate, truncate } from '../../views/default/myplugin/utils.mjs';
describe('formatDate', () => {
it('formats timestamps to readable dates', () => {
const result = formatDate(1700000000);
expect(result).toMatch(/\d{4}/);
});
});
describe('truncate', () => {
it('truncates long strings', () => {
expect(truncate('hello world', 5)).toBe('hello...');
});
it('leaves short strings unchanged', () => {
expect(truncate('hi', 5)).toBe('hi');
});
});
For modules that register or trigger Elgg hooks:
// tests/js/hooks.test.mjs
import { describe, it, expect, beforeEach } from 'vitest';
import * as hooks from '../mocks/hooks.mjs';
// Import the module under test
import { init } from '../../views/default/myplugin/init.mjs';
describe('myplugin hooks', () => {
beforeEach(() => {
hooks.reset();
});
it('registers a view hook', () => {
init();
const result = hooks.trigger('view', 'myplugin/widget', {}, '<div>original</div>');
expect(result).toContain('enhanced');
});
});
For modules that manipulate the DOM:
// tests/js/dropdown.test.mjs
import { describe, it, expect, beforeEach } from 'vitest';
import { JSDOM } from 'jsdom';
describe('dropdown', () => {
let document;
beforeEach(() => {
const dom = new JSDOM(`
<div class="elgg-menu">
<li class="elgg-menu-item-dropdown">
<a href="#">Menu</a>
<ul class="elgg-child-menu" style="display:none">
<li>Item 1</li>
</ul>
</li>
</div>
`);
document = dom.window.document;
});
it('toggles child menu visibility on click', async () => {
// Import and initialize with test document
const { initDropdown } = await import('../../views/default/myplugin/dropdown.mjs');
initDropdown(document);
const trigger = document.querySelector('.elgg-menu-item-dropdown > a');
trigger.click();
const childMenu = document.querySelector('.elgg-child-menu');
expect(childMenu.style.display).not.toBe('none');
});
});
For modules that make API calls:
// tests/js/api.test.mjs
import { describe, it, expect, vi } from 'vitest';
import Ajax from '../mocks/Ajax.mjs';
describe('wall post submission', () => {
it('sends post data to wall/status action', async () => {
const ajax = new Ajax();
const spy = vi.spyOn(ajax, 'action');
await ajax.action('wall/status', {
data: { body: 'Hello world', container_guid: 123 },
});
expect(spy).toHaveBeenCalledWith('wall/status', expect.objectContaining({
data: expect.objectContaining({ body: 'Hello world' }),
}));
});
});
docker compose -f docker/docker-compose.yml --profile test run --rm node sh -c \
"cd /plugin && npm ci && npm run test:js"
Add to .github/workflows/tests.yml:
js-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
- run: npm install
- run: npm run test:js
# 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 |
| Individual module logic | Third-party library integration (Dropzone, Select2, Parsley) |
| Fast iteration (no Elgg server) | Permission-dependent UI (logged in vs. out) |
| AMD/ESM import wiring | Multi-step workflows (upload → progress → success) |
Setup: tests/playwright/playwright.config.ts
import { defineConfig } from '@playwright/test';
export default defineConfig({
use: {
baseURL: 'http://elgg:8080', // Elgg container hostname in Docker network
screenshot: 'only-on-failure',
video: 'retain-on-failure',
},
testDir: './tests',
timeout: 30_000,
});
Shared login helper: tests/playwright/helpers/login.ts
import { Page } from '@playwright/test';
export async function loginAsAdmin(page: Page) {
await page.goto('/login');
await page.fill('input[name="username"]', 'admin');
await page.fill('input[name="password"]', 'password');
await page.click('[type=submit]');
await page.waitForURL(/\/dashboard/);
}
When JS adds/removes CSS classes in response to events, test the class directly.
// tests/playwright/tests/validation.spec.ts
import { test, expect } from '@playwright/test';
import { loginAsAdmin } from '../helpers/login';
test('shows inline error when required field is empty', async ({ page }) => {
await loginAsAdmin(page);
await page.goto('/path/to/form-page');
// Submit without filling required field
await page.click('[type=submit]');
// Field row should have error class
const field = page.locator('.elgg-field').filter({ has: page.locator('[name="title"]') });
await expect(field).toHaveClass(/elgg-field-has-errors/);
// Error message list should be visible
await expect(field.locator('.elgg-field-feedback')).toBeVisible();
await expect(field.locator('.elgg-field-feedback li')).toContainText(['required']);
});
test('clears error class when field is corrected', async ({ page }) => {
await loginAsAdmin(page);
await page.goto('/path/to/form-page');
await page.click('[type=submit]');
const field = page.locator('.elgg-field').filter({ has: page.locator('[name="title"]') });
await expect(field).toHaveClass(/elgg-field-has-errors/);
await page.fill('[name="title"]', 'Valid title');
await page.locator('[name="title"]').blur();
await expect(field).not.toHaveClass(/elgg-field-has-errors/);
});
For plugins that intercept form submit with JS (e.g. hypeajax-style forms):
// tests/playwright/tests/ajax-form.spec.ts
import { test, expect } from '@playwright/test';
test('submit button is disabled during AJAX request', async ({ page }) => {
await page.goto('/path/to/ajax-form');
// Intercept the action to delay response
await page.route('**/action/myplugin/save', async (route) => {
await new Promise(r => setTimeout(r, 500));
await route.fulfill({ json: { status: 0, value: {} } });
});
const btn = page.locator('[type=submit]');
await page.fill('[name="body"]', 'Test content');
await btn.click();
// Button should be disabled mid-flight
await expect(btn).toBeDisabled();
// After response, button re-enables
await expect(btn).toBeEnabled({ timeout: 2000 });
});
test('success callback runs and updates UI', async ({ page }) => {
await page.goto('/path/to/ajax-form');
await page.route('**/action/myplugin/save', (route) =>
route.fulfill({ json: { status: 0, value: { guid: 42 } } })
);
await page.fill('[name="body"]', 'Hello world');
await page.click('[type=submit]');
// Expect success feedback (class, message, or redirect)
await expect(page.locator('.elgg-system-messages')).toContainText('saved');
});
test('error response shows system error message', async ({ page }) => {
await page.goto('/path/to/ajax-form');
await page.route('**/action/myplugin/save', (route) =>
route.fulfill({ json: { status: -1, messages: { errors: ['Permission denied'] } } })
);
await page.fill('[name="body"]', 'Hello');
await page.click('[type=submit]');
await expect(page.locator('.elgg-system-messages')).toContainText('Permission denied');
});
For plugins using Dropzone or similar:
// tests/playwright/tests/file-upload.spec.ts
import { test, expect } from '@playwright/test';
import path from 'path';
test('file upload shows progress then success class', async ({ page }) => {
await page.goto('/path/to/upload-page');
// Set input file (works even with hidden inputs when Dropzone is active)
const dropzone = page.locator('.elgg-dropzone');
await expect(dropzone).toBeVisible();
// Use setInputFiles on the hidden fallback input
await page.setInputFiles('input[type=file]', path.join(__dirname, '../fixtures/test-image.jpg'));
// Progress bar should appear
const preview = page.locator('.dz-preview').first();
await expect(preview).toBeVisible();
// Wait for success class (Dropzone adds this after server confirms)
await expect(preview).toHaveClass(/elgg-dropzone-success/, { timeout: 10_000 });
// File name should appear in preview
await expect(preview.locator('.dz-filename')).toContainText('test-image');
});
test('failed upload shows error class and message', async ({ page }) => {
await page.route('**/action/dropzone/**', (route) =>
route.fulfill({ status: 500, body: 'Server error' })
);
await page.goto('/path/to/upload-page');
await page.setInputFiles('input[type=file]', path.join(__dirname, '../fixtures/test-image.jpg'));
const preview = page.locator('.dz-preview').first();
await expect(preview).toHaveClass(/elgg-dropzone-error/, { timeout: 5_000 });
await expect(preview.locator('.dz-error-message')).toBeVisible();
});
test('remove button deletes the file', async ({ page }) => {
await page.goto('/path/to/upload-page');
await page.setInputFiles('input[type=file]', path.join(__dirname, '../fixtures/test-image.jpg'));
const preview = page.locator('.dz-preview').first();
await expect(preview).toHaveClass(/elgg-dropzone-success/, { timeout: 10_000 });
// Click remove
await preview.locator('.dz-remove').click();
await expect(preview).not.toBeAttached();
});
For plugins that render data-src placeholders and load content via AJAX:
// tests/playwright/tests/placeholder.spec.ts
import { test, expect } from '@playwright/test';
test('placeholder loads deferred view content', async ({ page }) => {
await page.goto('/path/to/page-with-placeholder');
// Initially shows a loading spinner or nothing
const placeholder = page.locator('[data-src*="_deferred/"]').first();
await expect(placeholder).toBeVisible();
// Wait for content to load (JS replaces placeholder with real HTML)
await expect(placeholder).not.toHaveAttribute('data-src', { timeout: 5_000 });
// Actual content should be present
await expect(page.locator('.myplugin-widget-content')).toBeVisible();
});
test('deferred view does not make duplicate requests', async ({ page }) => {
const requests: string[] = [];
page.on('request', (req) => {
if (req.url().includes('_deferred')) requests.push(req.url());
});
await page.goto('/path/to/page-with-placeholder');
await page.waitForTimeout(2000);
// Count unique deferred URLs — should load each only once
const unique = new Set(requests);
expect(requests.length).toBe(unique.size);
});
For plugins that enhance <select> elements with Select2 or similar:
// tests/playwright/tests/autocomplete.spec.ts
import { test, expect } from '@playwright/test';
test('typing in autocomplete fetches and shows results', async ({ page }) => {
await page.goto('/path/to/form-with-autocomplete');
// Click the Select2 container to open
await page.click('.select2-container');
// Type in the search field that Select2 creates
await page.fill('.select2-search__field', 'admin');
// Mock or wait for AJAX results
await expect(page.locator('.select2-results__option')).toHaveCount({ minimum: 1 }, { timeout: 3_000 });
// Select first result
await page.locator('.select2-results__option').first().click();
// The underlying <select> should have the value set
const selectValue = await page.locator('select[name="user_guid"]').inputValue();
expect(selectValue).not.toBe('');
});
test('selected item renders with icon if provided', async ({ page }) => {
await page.route('**/path/to/autocomplete/source*', (route) =>
route.fulfill({
json: [{ id: 1, text: 'Admin User', icon: '/path/to/avatar.jpg' }]
})
);
await page.goto('/path/to/form-with-autocomplete');
await page.click('.select2-container');
await page.fill('.select2-search__field', 'Ad');
await page.locator('.select2-results__option').first().click();
// Rendered selection should contain an image
await expect(page.locator('.select2-selection .select-img')).toBeVisible();
});
For plugins with toggler buttons (attachments, collapsible panels, drawers):
// tests/playwright/tests/toggle.spec.ts
import { test, expect } from '@playwright/test';
test('attachments panel is hidden by default', async ({ page }) => {
await page.goto('/path/to/post-form');
await expect(page.locator('.attachments-form')).toBeHidden();
});
test('toggler button shows the attachments panel', async ({ page }) => {
await page.goto('/path/to/post-form');
await page.click('.attachments-toggler');
await expect(page.locator('.attachments-form')).toBeVisible();
});
test('form reset hides the attachments panel again', async ({ page }) => {
await page.goto('/path/to/post-form');
await page.click('.attachments-toggler');
await expect(page.locator('.attachments-form')).toBeVisible();
await page.click('[type=reset]');
await expect(page.locator('.attachments-form')).toBeHidden();
});
For plugins that show/hide UI based on login state or capabilities:
// tests/playwright/tests/permissions.spec.ts
import { test, expect } from '@playwright/test';
import { loginAsAdmin } from '../helpers/login';
test('action buttons hidden for guests', async ({ page }) => {
// No login — guest session
await page.goto('/path/to/content');
await expect(page.locator('.elgg-menu-entity .elgg-menu-item-edit')).not.toBeVisible();
});
test('edit button visible for content owner', async ({ page }) => {
await loginAsAdmin(page);
await page.goto('/path/to/content');
await expect(page.locator('.elgg-menu-entity .elgg-menu-item-edit')).toBeVisible();
});
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 message
await expect(page.locator('.elgg-system-messages .elgg-message-success')).toBeVisible({ timeout: 5_000 });
// Check for error system message
await expect(page.locator('.elgg-system-messages .elgg-message-error')).toBeVisible({ timeout: 5_000 });
<plugin>/
tests/
playwright/
playwright.config.ts
package.json # { "devDependencies": { "@playwright/test": "^1.x" } }
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
data-* driven initialization tested (element has attribute → JS activates)// Module definition
define('myplugin/utils', ['elgg', 'jquery'], function(elgg, $) {
return {
greet: function(name) {
return elgg.echo('greeting', [name]);
}
};
});
// Module usage
require(['myplugin/utils'], function(utils) {
console.log(utils.greet('World'));
});
Testing AMD: Use the AMD shim mock, or refactor the module to be testable by extracting pure logic into separate functions.
// views/default/myplugin/utils.mjs
import elgg from 'elgg';
export function greet(name) {
return elgg.echo('greeting', [name]);
}
Testing ESM: Direct import in Vitest with Elgg mocks via aliases.
| Step | From | To |
|---|---|---|
| 2.x→5.x | AMD define()/require() | Same (but module names may change) |
| 5.x→6.x | AMD define()/require() | ES import/export + .mjs extension |
Key 6.x JS changes:
elgg_define_js() → elgg_register_esm()elgg_require_js() → elgg_import_esm()require(['module'], fn) → import module from 'module'define('name', [...], fn) → export function/class/default<plugin>/
views/default/
myplugin/
utils.mjs # Module under test
widget.mjs
tests/
js/
mocks/
elgg.mjs # Elgg core mock
Ajax.mjs # Ajax mock
hooks.mjs # Hooks mock
jquery.mjs # jQuery mock
utils.test.mjs # Tests for utils.mjs
widget.test.mjs # Tests for widget.mjs
phpunit/ # PHP tests (separate)
...
vitest.config.ts
package.json
Vitest (unit):
Playwright (behavioral):