From cem
Generates Playwright or Puppeteer test scaffolding for custom elements using cem serve chromeless demo UI and Page Object Model.
npx claudepluginhub bennypowers/cem --plugin cemThis skill uses the workspace's default tool permissions.
Generate browser-based test scaffolding for custom elements using the
Guides Next.js Cache Components and Partial Prerendering (PPR) with cacheComponents enabled. Implements 'use cache', cacheLife(), cacheTag(), revalidateTag(), static/dynamic optimization, and cache debugging.
Migrates code, prompts, and API calls from Claude Sonnet 4.0/4.5 or Opus 4.1 to Opus 4.5, updating model strings on Anthropic, AWS, GCP, Azure platforms.
Automates semantic versioning and release workflow for Claude Code plugins: bumps versions in package.json, marketplace.json, plugin.json; verifies builds; creates git tags, GitHub releases, changelogs.
Generate browser-based test scaffolding for custom elements using the
Page Object Model pattern, testing against cem serve's chromeless demo UI
with Playwright or Puppeteer.
Read the target element's full manifest data:
cem://element/{tagName}
cem://element/{tagName}/attributes
cem://element/{tagName}/slots
cem://element/{tagName}/events
cem://element/{tagName}/css/parts
cem://element/{tagName}/css/custom-properties
cem://element/{tagName}/css/states
Check which demos exist for the element — the manifest's demos array defines
the available demo pages and their URLs.
Search the project for existing test patterns:
Look for existing test files to match conventions:
*.test.ts, *.spec.ts patterns@playwright/test) or Puppeteer (puppeteer)playwright.config.ts or puppeteer setuptests/pages/, tests/models/, etc.Check package.json for test dependencies and scripts
If no test framework is present, recommend Playwright and generate a config
If the project doesn't have a Playwright config, generate one that starts
cem serve in chromeless mode:
// playwright.config.ts
import { defineConfig } from '@playwright/test';
export default defineConfig({
webServer: {
command: 'cem serve --rendering=chromeless',
port: 8000,
reuseExistingServer: !process.env.CI,
},
use: {
baseURL: 'http://localhost:8000',
},
});
For Puppeteer, generate equivalent setup/teardown that spawns cem serve.
Generate a page object class for the element that encapsulates all locators and interactions derived from the manifest. The page object is the primary output — tests become thin and readable when the page object does the work.
// tests/pages/MyButtonPage.ts
import type { Locator, Page } from '@playwright/test';
export class MyButtonPage {
readonly page: Page;
/** The <my-button> element */
readonly host: Locator;
// --- Slots (from manifest slots) ---
/** Default slot content */
readonly slotDefault: Locator;
/** The icon slot */
readonly slotIcon: Locator;
constructor(page: Page) {
this.page = page;
this.host = page.locator('my-button');
this.slotDefault = this.host.locator('> :not([slot])');
this.slotIcon = this.host.locator('[slot="icon"]');
}
// --- Navigation ---
async goto(demo = '') {
const path = demo
? `/elements/my-button/demo/${demo}`
: '/elements/my-button/demo/';
await this.page.goto(path);
}
// --- Attribute helpers (from manifest attributes) ---
async variant(): Promise<string | null> {
return this.host.getAttribute('variant');
}
async setVariant(value: 'primary' | 'secondary' | 'danger') {
await this.host.evaluate((el, v) => el.setAttribute('variant', v), value);
}
async disabled(): Promise<boolean> {
return this.host.evaluate((el) => el.hasAttribute('disabled'));
}
async setDisabled(value: boolean) {
await this.host.evaluate(
(el, v) => el.toggleAttribute('disabled', v),
value,
);
}
// --- Property helpers (for non-attribute properties) ---
async setComplexData(value: unknown) {
await this.host.evaluate((el, v) => (el as any).complexData = v, value);
}
// --- Event helpers (from manifest events) ---
/**
* Returns a promise that resolves with serializable event data when the
* element fires the given event. Call this BEFORE triggering the interaction.
*
* DOM Event objects are not serializable across the Playwright boundary.
* This extracts the event's own enumerable properties (the class fields
* on typed Event subclasses) into a plain object.
*/
async waitForEvent(eventName: string): Promise<Record<string, unknown>> {
return this.host.evaluate(
(el, name) =>
new Promise<Record<string, unknown>>((resolve) =>
el.addEventListener(
name,
(ev) => {
const data: Record<string, unknown> = { type: ev.type };
for (const key of Object.keys(ev)) {
data[key] = (ev as Record<string, unknown>)[key];
}
resolve(data);
},
{ once: true },
),
),
eventName,
);
}
// --- CSS helpers (from manifest CSS custom properties) ---
async setCssProperty(name: string, value: string) {
await this.host.evaluate(
(el, [n, v]) => el.style.setProperty(n, v),
[name, value] as const,
);
}
async computedStyle(property: string, internalSelector?: string): Promise<string> {
return this.host.evaluate(
(el, [prop, selector]) => {
const target = selector
? el.shadowRoot?.querySelector(selector) ?? el
: el;
return getComputedStyle(target).getPropertyValue(prop);
},
[property, internalSelector ?? null] as const,
);
}
// --- State helpers (from manifest CSS states) ---
async matchesState(state: string): Promise<boolean> {
return this.host.evaluate((el, s) => el.matches(`:state(${s})`), state);
}
// --- Interaction helpers ---
async click() {
await this.host.click();
}
async focus() {
await this.host.focus();
}
}
For each manifest feature, generate the corresponding page object members:
| Manifest Feature | Page Object Member |
|---|---|
| Each attribute | Getter method + setter method (typed for enums) |
| Each property (no attribute) | Setter via evaluate |
| Each named slot | Locator field: this.host.locator('[slot="name"]') |
| Default slot | Locator field: this.host.locator('> :not([slot])') |
| Each CSS part | Helper method using evaluate to query shadow root |
| Each CSS custom property | Helper via setCssProperty / computedStyle |
| Each CSS state | matchesState(name) boolean helper |
| Each event | Typed waitForEvent usage or dedicated method |
| Each demo URL | Named navigation method or param to goto() |
Tests use the page object — they should read almost like plain English.
// tests/my-button.spec.ts
import { test, expect } from '@playwright/test';
import { MyButtonPage } from './pages/MyButtonPage';
test.describe('<my-button>', () => {
let button: MyButtonPage;
test.beforeEach(async ({ page }) => {
button = new MyButtonPage(page);
await button.goto();
});
test.describe('attributes', () => {
test('has primary variant by default', async () => {
await expect(button.host).toHaveAttribute('variant', 'primary');
});
test('reflects variant attribute', async () => {
await button.setVariant('secondary');
await expect(button.host).toHaveAttribute('variant', 'secondary');
});
test('supports all variant values', async () => {
for (const variant of ['primary', 'secondary', 'danger'] as const) {
await button.setVariant(variant);
await expect(button.host).toHaveAttribute('variant', variant);
}
});
test('toggles disabled', async () => {
expect(await button.disabled()).toBe(false);
await button.setDisabled(true);
expect(await button.disabled()).toBe(true);
});
});
test.describe('slots', () => {
test('projects default slot content', async () => {
await expect(button.slotDefault.first()).toBeVisible();
});
test('projects icon slot content', async () => {
// Only test if the demo includes icon slot content
await expect(button.slotIcon).toBeAttached();
});
});
test.describe('events', () => {
test('fires change event', async () => {
const eventData = button.waitForEvent('change');
await button.setVariant('secondary');
expect((await eventData).type).toBe('change');
});
});
test.describe('css custom properties', () => {
test('--my-button-color overrides text color', async () => {
await button.setCssProperty('--my-button-color', 'red');
const color = await button.computedStyle('color', '#button');
expect(color).toBe('rgb(255, 0, 0)');
});
});
test.describe('css states', () => {
test('matches :state(loading) when loading', async () => {
await button.host.evaluate((el: any) => (el.loading = true));
expect(await button.matchesState('loading')).toBe(true);
});
});
test.describe('accessibility', () => {
test('has button role', async () => {
await expect(button.host).toHaveRole('button');
});
test('is keyboard focusable', async ({ page }) => {
await page.keyboard.press('Tab');
await expect(button.host).toBeFocused();
});
});
test.describe('visual regression', () => {
test('default appearance', async () => {
await expect(button.host).toHaveScreenshot('my-button-default.png');
});
});
});
Generate both files:
tests/
├── pages/
│ └── MyButtonPage.ts # Page object
└── my-button.spec.ts # Test cases
Or match the project's existing test directory structure if one exists.
cem serve provides a chromeless rendering mode designed for testing:
cem serve --rendering=chromelesshttp://localhost:8000/elements/{tag-name}/demo/?rendering=chromeless to any demo URL
to get chromeless mode even when the server runs in default mode{PascalCaseTag}Page (e.g., MyButtonPage)variant accepts 'primary' | 'secondary', type the setter parameter'reflects the variant attribute' not 'works'// TODO: comment explaining what to fill inpage.$eval, page.waitForSelector, etc. instead of Playwright locators, but keep the same class structure and method names