| name | testing-widgets |
| description | Testing StickerNest widgets and components. Use when the user asks to test widgets, write tests, add tests, create test cases, run tests, debug tests, or verify widget functionality. Covers Playwright E2E tests, Vitest unit tests, widget integration testing, and pipeline testing. |
Testing StickerNest Widgets
This skill covers testing patterns for StickerNest widgets and components using Playwright (E2E) and Vitest (unit tests).
Test Stack Overview
| Tool | Purpose | Location |
|---|---|---|
| Playwright | E2E browser tests | tests/*.spec.ts |
| Vitest | Unit tests | src/**/*.test.ts |
Running Tests
# E2E Tests (Playwright)
npm run test # Run all E2E tests
npm run test:ui # Interactive UI mode
npm run test:headed # Tests with visible browser
# Unit Tests (Vitest)
npm run test:unit # Run all unit tests
npm run test:unit:watch # Watch mode
Playwright E2E Tests
Test File Structure
// tests/my-widget.spec.ts
import { test, expect } from '@playwright/test';
test.describe('My Widget', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/');
// Wait for app to load
await page.waitForSelector('[data-testid="canvas"]');
});
test('should load and display correctly', async ({ page }) => {
// Test implementation
});
});
Common Test Patterns
Adding Widget to Canvas
test('should add widget to canvas', async ({ page }) => {
// Open widget library
await page.click('[data-testid="library-tab"]');
// Search for widget
await page.fill('[data-testid="widget-search"]', 'my-widget');
// Click to add
await page.click('[data-testid="widget-card-my-widget"]');
// Verify widget appears on canvas
await expect(page.locator('[data-widget-id*="my-widget"]')).toBeVisible();
});
Testing Widget Interactions
test('should respond to clicks', async ({ page }) => {
// Add widget first...
// Get widget iframe
const widgetFrame = page.frameLocator('[data-widget-id*="my-widget"] iframe');
// Click element inside widget
await widgetFrame.locator('#button').click();
// Verify result
await expect(widgetFrame.locator('#count')).toHaveText('1');
});
Testing Widget Communication
test('should emit events to connected widgets', async ({ page }) => {
// Add source widget
await addWidget(page, 'counter');
// Add target widget
await addWidget(page, 'display');
// Create connection (via UI or programmatically)
await createConnection(page, 'counter', 'valueChanged', 'display', 'data.set');
// Interact with source
const counterFrame = page.frameLocator('[data-widget-id*="counter"] iframe');
await counterFrame.locator('#increment').click();
// Verify target received data
const displayFrame = page.frameLocator('[data-widget-id*="display"] iframe');
await expect(displayFrame.locator('#value')).toHaveText('1');
});
Testing Widget State Persistence
test('should persist state across reload', async ({ page }) => {
// Add widget and modify state
const widgetFrame = page.frameLocator('[data-widget-id*="counter"] iframe');
await widgetFrame.locator('#increment').click();
await widgetFrame.locator('#increment').click();
// Save canvas
await page.click('[data-testid="save-canvas"]');
// Reload page
await page.reload();
await page.waitForSelector('[data-testid="canvas"]');
// Verify state persisted
const reloadedFrame = page.frameLocator('[data-widget-id*="counter"] iframe');
await expect(reloadedFrame.locator('#count')).toHaveText('2');
});
Test Helpers
Create reusable helpers in tests/helpers.ts:
import { Page, expect } from '@playwright/test';
export async function addWidget(page: Page, widgetId: string) {
await page.click('[data-testid="library-tab"]');
await page.fill('[data-testid="widget-search"]', widgetId);
await page.click(`[data-testid="widget-card-${widgetId}"]`);
await page.waitForSelector(`[data-widget-id*="${widgetId}"]`);
}
export async function getWidgetFrame(page: Page, widgetId: string) {
return page.frameLocator(`[data-widget-id*="${widgetId}"] iframe`);
}
export async function waitForWidgetReady(page: Page, widgetId: string) {
await page.waitForFunction(
(id) => {
const widget = document.querySelector(`[data-widget-id*="${id}"]`);
return widget?.getAttribute('data-lifecycle') === 'ready';
},
widgetId,
{ timeout: 5000 }
);
}
export async function createConnection(
page: Page,
sourceId: string,
sourcePort: string,
targetId: string,
targetPort: string
) {
// Implementation depends on your connection UI
// This is a placeholder for programmatic connection
}
Vitest Unit Tests
Test File Structure
// src/widgets/builtin/MyWidget.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { MyWidgetManifest } from './MyWidget';
describe('MyWidget', () => {
describe('Manifest', () => {
it('should have valid id format', () => {
expect(MyWidgetManifest.id).toMatch(/^stickernest\.[a-z-]+$/);
});
it('should have required fields', () => {
expect(MyWidgetManifest.id).toBeDefined();
expect(MyWidgetManifest.name).toBeDefined();
expect(MyWidgetManifest.version).toBeDefined();
expect(MyWidgetManifest.entry).toBeDefined();
});
it('should have valid version format', () => {
expect(MyWidgetManifest.version).toMatch(/^\d+\.\d+\.\d+$/);
});
});
});
Testing Manifest Validation
import { validateManifest } from '../../runtime/ManifestValidator';
describe('Manifest Validation', () => {
it('should pass validation', () => {
const result = validateManifest(MyWidgetManifest);
expect(result.valid).toBe(true);
expect(result.errors).toHaveLength(0);
});
it('should have matching input/output declarations', () => {
const { inputs, outputs, io } = MyWidgetManifest;
// Check io.inputs have corresponding input ports
if (io?.inputs) {
io.inputs.forEach(inputId => {
const baseId = inputId.split('.')[0];
expect(inputs).toHaveProperty(baseId);
});
}
// Check io.outputs have corresponding output ports
if (io?.outputs) {
io.outputs.forEach(outputId => {
const baseId = outputId.split('.')[0];
expect(outputs).toHaveProperty(baseId);
});
}
});
});
Testing Port Compatibility
import { isPortCompatible } from '../../runtime/PortCompatibility';
describe('Port Compatibility', () => {
it('should allow same types', () => {
expect(isPortCompatible('string', 'string')).toBe(true);
expect(isPortCompatible('number', 'number')).toBe(true);
});
it('should allow any type to receive all', () => {
expect(isPortCompatible('string', 'any')).toBe(true);
expect(isPortCompatible('number', 'any')).toBe(true);
expect(isPortCompatible('object', 'any')).toBe(true);
});
it('should reject incompatible types', () => {
expect(isPortCompatible('string', 'number')).toBe(false);
expect(isPortCompatible('trigger', 'string')).toBe(false);
});
});
Testing Pipeline Runtime
import { PipelineRuntime } from '../../runtime/PipelineRuntime';
import { EventBus } from '../../runtime/EventBus';
describe('PipelineRuntime', () => {
let runtime: PipelineRuntime;
let eventBus: EventBus;
beforeEach(() => {
eventBus = new EventBus();
runtime = new PipelineRuntime({ eventBus });
});
it('should route events between connected widgets', async () => {
const received: any[] = [];
// Subscribe to target
eventBus.on('widget:input', (event) => {
if (event.targetWidgetId === 'widget-b') {
received.push(event);
}
});
// Create connection
runtime.addConnection({
id: 'conn-1',
sourceNodeId: 'widget-a',
sourcePortId: 'output',
targetNodeId: 'widget-b',
targetPortId: 'input',
});
// Emit from source
runtime.handleOutput('widget-a', 'output', { value: 42 });
expect(received).toHaveLength(1);
expect(received[0].payload.value).toBe(42);
});
});
Mocking WidgetAPI
import { vi } from 'vitest';
function createMockWidgetAPI() {
return {
onMount: vi.fn(),
onDestroy: vi.fn(),
onInput: vi.fn(),
onStateChange: vi.fn(),
setState: vi.fn(),
emitOutput: vi.fn(),
emit: vi.fn(),
on: vi.fn(),
log: vi.fn(),
getAssetUrl: vi.fn((path) => `/assets/${path}`),
};
}
describe('Widget HTML Logic', () => {
let mockAPI: ReturnType<typeof createMockWidgetAPI>;
beforeEach(() => {
mockAPI = createMockWidgetAPI();
(global as any).window = { WidgetAPI: mockAPI };
});
it('should register mount handler', () => {
// Execute widget script logic
// ...
expect(mockAPI.onMount).toHaveBeenCalled();
});
});
Testing Patterns by Widget Type
Display Widget Tests
test('should update display when content changes', async ({ page }) => {
const frame = await getWidgetFrame(page, 'basic-text');
// Send input via debug panel or programmatically
await sendWidgetInput(page, 'basic-text', 'text.set', 'New Content');
await expect(frame.locator('#text')).toHaveText('New Content');
});
Interactive Widget Tests
test('should handle user interaction', async ({ page }) => {
const frame = await getWidgetFrame(page, 'counter');
// Initial state
await expect(frame.locator('#count')).toHaveText('0');
// Click increment
await frame.locator('#increment').click();
// Verify update
await expect(frame.locator('#count')).toHaveText('1');
});
Timer Widget Tests
test('should count down correctly', async ({ page }) => {
const frame = await getWidgetFrame(page, 'timer');
// Start timer
await frame.locator('#start').click();
// Wait for countdown
await page.waitForTimeout(1100); // 1 second + buffer
// Verify time decreased
const timeText = await frame.locator('#time').textContent();
expect(parseInt(timeText || '60')).toBeLessThan(60);
});
Existing Test Files Reference
| File | Tests |
|---|---|
tests/widget-basics.spec.ts |
Widget loading, lifecycle |
tests/widget-integration.spec.ts |
Cross-widget communication |
tests/pipeline-routing.spec.ts |
Pipeline connections |
tests/pipeline-creation.spec.ts |
Pipeline UI |
tests/ai-pipeline-integration.spec.ts |
AI widget generation |
tests/widget-generation.spec.ts |
Widget creation |
src/runtime/PipelineRuntime.test.ts |
Pipeline unit tests |
src/runtime/PortCompatibility.test.ts |
Port compatibility |
src/runtime/EventBus.test.ts |
Event bus unit tests |
src/state/usePanelsStore.test.ts |
Store tests |
Debugging Tests
Playwright Debug Mode
# Run with debug UI
npx playwright test --debug
# Run specific test file
npx playwright test tests/my-widget.spec.ts
# Run with trace on failure
npx playwright test --trace on
Vitest Debug
# Run specific test file
npx vitest run src/widgets/builtin/MyWidget.test.ts
# Watch specific file
npx vitest watch src/widgets/builtin/MyWidget.test.ts
Console Logging in E2E Tests
test('debug widget', async ({ page }) => {
// Capture console logs
page.on('console', msg => console.log('PAGE LOG:', msg.text()));
// Capture errors
page.on('pageerror', err => console.error('PAGE ERROR:', err));
// Your test...
});
Test Configuration
Playwright Config
Located at playwright.config.ts:
export default defineConfig({
testDir: './tests',
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
reporter: 'html',
use: {
baseURL: 'http://localhost:5173',
trace: 'on-first-retry',
},
webServer: {
command: 'npm run dev',
url: 'http://localhost:5173',
reuseExistingServer: !process.env.CI,
},
});
Vitest Config
Located in vite.config.ts:
export default defineConfig({
test: {
globals: true,
environment: 'jsdom',
include: ['src/**/*.test.ts'],
},
});