# Testing Widgets

> 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.

- Skill: `majiayu000/testing-widgets` (Agent Skill, multi-file: 2 files)
- Install (CLI): `npx skillmds@latest add majiayu000/testing-widgets`
- Raw SKILL.md: https://api.skillmd.com/api/skills/majiayu000/testing-widgets/raw
- Safety review: pending (external: skill-scanner PASS, skillspector PASS)
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: DevOps & Infra
- Author: majiayu000 (https://skillmd.com/u/majiayu000)
- Updated: 2026-09-09
- Page: https://skillmd.com/skills/majiayu000/testing-widgets

---


# 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

```bash
# 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

```typescript
// 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

```typescript
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

```typescript
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

```typescript
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

```typescript
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`:

```typescript
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

```typescript
// 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

```typescript
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

```typescript
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

```typescript
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

```typescript
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

```typescript
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

```typescript
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

```typescript
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

```bash
# 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

```bash
# 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

```typescript
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`:

```typescript
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`:

```typescript
export default defineConfig({
  test: {
    globals: true,
    environment: 'jsdom',
    include: ['src/**/*.test.ts'],
  },
});
```

