Skills for AEM Forms on Edge Delivery Services: building forms with the Form block.
96%
Does it follow best practices?
Impact
—
Average score across 6 eval scenarios
Passed
No findings from the security scan
/*
* Playwright harness for end-to-end tests of AEM Forms custom components.
*
* Renders a form from a form definition (form JSON) without any deployed page: a local page is
* created whose form block carries the JSON in <pre><code>, the page loads the project's
* head.html (aem.js + scripts.js), and the repository root is served over HTTP so the real form
* block, rule engine, mappings.js and component JS/CSS run exactly as they do on a live site.
*
* Generated by the create-custom-component skill. Imports only @playwright/test and Node built-ins,
* so it runs with the repository's own Playwright config (e.g. npx playwright test <this folder>).
*/
import { test as base, expect } from '@playwright/test';
import { createServer } from 'node:http';
import { existsSync, readFileSync, statSync } from 'node:fs';
import {
dirname, extname, join, normalize, sep,
} from 'node:path';
import { fileURLToPath } from 'node:url';
const HERE = dirname(fileURLToPath(import.meta.url));
/** Repository root: the nearest folder above this file that contains blocks/form. */
function findRoot(start) {
let dir = start;
while (!existsSync(join(dir, 'blocks/form'))) {
const parent = dirname(dir);
if (parent === dir) return process.cwd();
dir = parent;
}
return dir;
}
export const ROOT = findRoot(HERE);
const MIME = {
'.js': 'text/javascript',
'.mjs': 'text/javascript',
'.css': 'text/css',
'.json': 'application/json',
'.html': 'text/html',
'.svg': 'image/svg+xml',
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.woff2': 'font/woff2',
'.woff': 'font/woff',
};
const DEFAULT_HEAD = `<meta name="viewport" content="width=device-width, initial-scale=1"/>
<script src="/scripts/aem.js" type="module"></script>
<script src="/scripts/scripts.js" type="module"></script>
<link rel="stylesheet" href="/styles/styles.css"/>`;
function startStaticServer(root) {
const rootWithSep = root.endsWith(sep) ? root : root + sep;
const server = createServer((req, res) => {
const { pathname } = new URL(req.url, 'http://localhost');
const file = normalize(join(root, decodeURIComponent(pathname)));
if (!file.startsWith(rootWithSep) || !existsSync(file) || !statSync(file).isFile()) {
res.writeHead(404, { 'content-type': 'text/plain' });
res.end('not found');
return;
}
res.writeHead(200, { 'content-type': MIME[extname(file)] || 'application/octet-stream' });
res.end(readFileSync(file));
});
return new Promise((resolve) => {
server.listen(0, '127.0.0.1', () => resolve(server));
});
}
const escapeHtml = (s) => s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
/** HTML of an Edge Delivery Services page whose form block holds the form definition in <pre><code>. */
export function formPageHtml(formDef) {
const headFile = join(ROOT, 'head.html');
const head = existsSync(headFile) ? readFileSync(headFile, 'utf8') : DEFAULT_HEAD;
return `<!DOCTYPE html>
<html>
<head>
<title>${escapeHtml(formDef.title || 'Form component test')}</title>
${head}
</head>
<body>
<header></header>
<main>
<div>
<div class="form"><div><div><pre><code>${escapeHtml(JSON.stringify(formDef))}</code></pre></div></div></div>
</div>
</main>
<footer></footer>
</body>
</html>`;
}
/** Reads <name>/<file> next to this harness (the component's form JSON by default). */
export function loadFormJson(name, file = `${name}.form.json`) {
return JSON.parse(readFileSync(join(HERE, name, file), 'utf8'));
}
export const test = base.extend({
// eslint-disable-next-line no-empty-pattern
formServer: [async ({}, use) => {
const server = await startStaticServer(ROOT);
await use(`http://127.0.0.1:${server.address().port}`);
server.close();
}, { scope: 'worker' }],
/*
* Collects console errors; fails the test on errors raised by the form block or components.
* mappings.js swallows exceptions thrown by a component's decorate() (it logs
* "failed to load component ..." and still sets data-component-status="loaded"), so those
* log lines are treated as errors too.
*/
consoleErrors: [async ({ page }, use) => {
const errors = [];
page.on('console', (msg) => {
const text = msg.text();
if (msg.type() === 'error') errors.push({ text, url: msg.location()?.url || '' });
else if (text.startsWith('failed to load component')) errors.push({ text, url: '/blocks/form/mappings.js' });
});
page.on('pageerror', (err) => errors.push({ text: `${err.message}\n${err.stack || ''}`, url: '' }));
await use(errors);
const formErrors = errors.filter((e) => `${e.url} ${e.text}`.includes('/blocks/form/'));
expect(formErrors.map((e) => e.text), 'console errors from the form block / custom components').toEqual([]);
}, { auto: true }],
});
/**
* Renders the form definition on a local page and waits until the form model exists and every
* custom component has finished loading.
*/
export async function renderForm(page, formServer, formDefinition) {
const formDef = { id: 'test-form', ...formDefinition };
// createForm() passes the last segment of `action` to components as formId; the rule engine
// keys subscriptions by `id`. Keep them equal so subscribe() callbacks fire, as on AEM.
if (!String(formDef.action || '').endsWith(`/${formDef.id}`)) {
formDef.action = `/adobe/forms/af/submit/${formDef.id}`;
}
const url = `${formServer}/__form-test__/${encodeURIComponent(formDef.id)}.html`;
await page.route(url, (route) => route.fulfill({ contentType: 'text/html', body: formPageHtml(formDef) }));
await page.goto(url);
await page.waitForFunction(() => window.myForm && document.querySelector('form'));
await page.waitForFunction(() => !document.querySelector('[data-component-status="loading"]'));
}
/** Locator for a field wrapper by model id. */
export const field = (page, id) => page.locator(`[data-id="${id}"]`);
/** Reads a model property (default `value`) of the field with the given id. */
export function getModel(page, id, prop = 'value') {
return page.evaluate(([fid, p]) => {
const v = window.myForm.getElement(fid)?.[p];
return v === undefined ? undefined : JSON.parse(JSON.stringify(v));
}, [id, prop]);
}
/** Sets a model property, as a rule or prefill would (value, enum, enumNames, visible, enabled, ...). */
export function setModel(page, id, prop, value) {
return page.evaluate(([fid, p, v]) => { window.myForm.getElement(fid)[p] = v; }, [id, prop, value]);
}
/** Dispatches a model event on a field, e.g. dispatchModelEvent(page, id, 'custom:otpVerified'). */
export function dispatchModelEvent(page, id, type, payload = {}) {
return page.evaluate(([fid, t, p]) => {
window.myForm.getElement(fid).dispatch({ type: t, payload: p });
}, [id, type, payload]);
}
/**
* Starts counting model change events of a field. Returns an async function
* `count(prop = 'value')` with the number of changes to `prop` since tracking started —
* use it to assert how often rules bound to the field's change event would fire.
*/
export async function trackChanges(page, id) {
await page.evaluate((fid) => {
window.__fieldChanges = window.__fieldChanges || {};
const counts = {};
window.__fieldChanges[fid] = counts;
window.myForm.getElement(fid).subscribe((e) => {
(e.payload?.changes || []).forEach((c) => {
counts[c.propertyName] = (counts[c.propertyName] || 0) + 1;
});
}, 'change');
}, id);
return (prop = 'value') => page.evaluate(([fid, p]) => window.__fieldChanges[fid][p] || 0, [id, prop]);
}
export { expect };