CtrlK
BlogDocsLog inGet started
Tessl Logo
PluginPublic

adobe/aem-forms-edge-delivery-services

Skills for AEM Forms on Edge Delivery Services: building forms with the Form block.

Quality

96%

Does it follow best practices?

Impact

—

Average score across 6 eval scenarios

SecuritybySnyk

Passed

No findings from the security scan

Overview
Quality
Evals
Security
Files

form-harness.jsskills/create-custom-component/assets/e2e/

/*
 * 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');

/** 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 };

README.md

tile.json