From idd-skills
End-to-end test development from user journeys. Use when creating or updating e2e tests that validate user journeys. Consumes journey narratives and journey maps from specs/, produces Playwright tests in frontend.
How this skill is triggered — by the user, by Claude, or both
Slash command
/idd-skills:e2e-journey-testing [journey-name][journey-name]This skill is limited to the following tools:
The summary Claude sees in its skill listing — used to decide when to auto-load this skill
Translate user journeys into executable end-to-end tests. Ensures the full user experience works as designed across the frontend/backend boundary.
Translate user journeys into executable end-to-end tests. Ensures the full user experience works as designed across the frontend/backend boundary.
toBeEditable + retry + value stability check).Specs directory (source of truth):
specs/
├── journeys/
│ └── {journey-name}.md ← narrative
└── journey-maps/
└── {journey-name}.map.yaml ← test bridge
Frontend directory (implementation):
frontend/
└── e2e/
├── journeys/
│ └── {journey-name}.spec.ts ← executable test
└── support/
├── fixtures.ts ← loaded from specs/fixtures
├── auth.ts ← Supabase auth helpers
└── api.ts ← API helpers
The journey map bridges narrative to technical implementation:
# specs/journey-maps/{journey-name}.map.yaml
id: {journey-name}
type: journey-map
journey: {journey-name} # matches journey filename
description: {purpose of this journey}
sources:
journey: specs/journeys/{journey-name}.md
stories:
- specs/stories/{area}/{story}.md
features:
- specs/features/{area}/{feature}.feature
preconditions:
auth: {persona-type} # or 'none' for unauthenticated
state: {description} # any required setup
steps:
{step-id}: # kebab-case identifier
journey_step: {number} # reference to journey doc
title: "{step title}" # matches journey heading
setup: # optional pre-step setup
- type: api
method: POST
endpoint: /resource
body: "{{fixtures.resource}}"
capture: resourceId # save for later use
actions: # user interactions
- type: navigate
url: "/path/{{resourceId}}"
- type: click
target: "[data-testid='button-name']"
- type: fill
target: "[data-testid='input-name']"
value: "{{fixtures.fieldValue}}"
- type: select
target: "[data-testid='select-name']"
value: "{option-value}"
- type: wait
for: networkidle | selector | timeout
value: "{selector or ms}"
assertions: # verifications
- type: visible
selector: "[data-testid='element']"
description: "Element is visible"
- type: hidden
selector: "[data-testid='element']"
- type: text
selector: "[data-testid='element']"
contains: "{partial text}"
# or: equals: "{exact text}"
- type: url
pattern: "{regex or exact path}"
- type: api
endpoint: "{METHOD} {path}"
expected_status: {code}
expected_body: # partial match
field: value
- type: count
selector: "[data-testid='item']"
equals: {number}
- type: polling
endpoint: "{path}"
until:
field: {json path}
equals: {value}
timeout: {duration}
fixtures:
{name}:
ref: specs/fixtures/{path}.json # from specs
{name}:
inline: # or inline for simple cases
field: value
cleanup: # optional teardown
- type: api
method: DELETE
endpoint: "/resource/{{resourceId}}"
// frontend/e2e/journeys/{journey-name}.spec.ts
import { test, expect } from '@playwright/test';
import { authenticateAs } from '../support/auth';
import { api } from '../support/api';
import { fixtures } from '../support/fixtures';
/**
* Journey: {Journey Title}
* Source: specs/journeys/{journey-name}.md
* Map: specs/journey-maps/{journey-name}.map.yaml
* Stories:
* - specs/stories/{area}/{story}.md
* Features:
* - specs/features/{area}/{feature}.feature
*/
test.describe('Journey: {Journey Title}', () => {
// Captured values from setup
let resourceId: string;
test.beforeEach(async ({ page, request }) => {
// Auth setup per preconditions
await authenticateAs(page, '{persona-type}');
// State setup if needed
const response = await api.post(request, '/resource', fixtures.resource);
resourceId = response.id;
});
test.afterEach(async ({ request }) => {
// Cleanup if needed
await api.delete(request, `/resource/${resourceId}`);
});
test('Step 1: {Step Title}', async ({ page }) => {
// Navigate
await page.goto('/path');
// Actions
await page.getByTestId('button').click();
// Assertions
await expect(page.getByTestId('element')).toBeVisible();
await expect(page.getByTestId('status')).toContainText('expected');
});
test('Step 2: {Step Title}', async ({ page }) => {
// Continue journey...
});
// Edge cases as separate tests
test('Edge: {Edge case description}', async ({ page }) => {
// Test edge case...
});
});
Use data-testid attributes for stable selectors:
<!-- In Angular component template -->
<button data-testid="create-audit-cta">Create Audit</button>
<input data-testid="entity-name-input" />
<div data-testid="audit-status">{{ audit.status }}</div>
# In journey map
- type: click
target: "[data-testid='create-audit-cta']"
Naming convention:
{component}-{element} → create-audit-cta{feature}-{component}-{element} → dashboard-audit-list-item{item}-{index} or use :nth-child() → audit-item-0Never use:
// frontend/e2e/support/fixtures.ts
// Import from specs directory
import createAudit from '../../../specs/fixtures/audits/create-audit.json';
import cancelAudit from '../../../specs/fixtures/audits/cancel-audit.json';
export const fixtures = {
audit: {
create: createAudit.request,
createExpected: createAudit.response,
cancel: cancelAudit.request,
}
};
// frontend/e2e/support/auth.ts
import { Page } from '@playwright/test';
type Persona = 'new-user' | 'existing-user' | 'admin';
export async function authenticateAs(page: Page, persona: Persona) {
// Get test credentials for persona
const credentials = getTestCredentials(persona);
// Authenticate with Supabase
const session = await supabaseAuth(credentials);
// Set session in browser
await page.context().addCookies([
{
name: 'sb-access-token',
value: session.access_token,
domain: 'localhost',
path: '/',
},
{
name: 'sb-refresh-token',
value: session.refresh_token,
domain: 'localhost',
path: '/',
}
]);
}
function getTestCredentials(persona: Persona) {
// Return test user credentials based on persona
// These should be seeded in test environment
const users = {
'new-user': { email: '[email protected]', password: 'test123' },
'existing-user': { email: '[email protected]', password: 'test123' },
'admin': { email: '[email protected]', password: 'test123' },
};
return users[persona];
}
// frontend/e2e/support/api.ts
import { APIRequestContext } from '@playwright/test';
const BASE_URL = process.env.API_URL || 'http://localhost:8080/api/v1';
export const api = {
async get(request: APIRequestContext, path: string) {
const response = await request.get(`${BASE_URL}${path}`);
return response.json();
},
async post(request: APIRequestContext, path: string, body: unknown) {
const response = await request.post(`${BASE_URL}${path}`, {
data: body,
});
return response.json();
},
async delete(request: APIRequestContext, path: string) {
await request.delete(`${BASE_URL}${path}`);
},
async waitFor(
request: APIRequestContext,
path: string,
condition: (data: unknown) => boolean,
timeout = 30000
) {
const start = Date.now();
while (Date.now() - start < timeout) {
const data = await this.get(request, path);
if (condition(data)) return data;
await new Promise(r => setTimeout(r, 1000));
}
throw new Error(`Timeout waiting for condition on ${path}`);
}
};
// frontend/playwright.config.ts
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './e2e',
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
reporter: 'html',
use: {
baseURL: 'http://localhost:4200',
trace: 'on-first-retry',
screenshot: 'only-on-failure',
video: process.env.PW_VIDEO === '1' ? 'on' : 'retain-on-failure',
},
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
],
webServer: [
{
command: 'cd ../backend && ./mvnw spring-boot:run',
url: 'http://localhost:8080/actuator/health',
reuseExistingServer: !process.env.CI,
},
{
command: 'npm run start',
url: 'http://localhost:4200',
reuseExistingServer: !process.env.CI,
},
],
});
Note: prefer config/env for video capture. npx playwright test --video=on is not supported by all Playwright CLI versions.
page.route(...) mocks before page.goto(...).# In CI workflow
e2e:
needs: [backend-build, frontend-build]
steps:
- uses: actions/checkout@v4
- name: Install dependencies
run: |
cd frontend
npm ci
npx playwright install --with-deps
- name: Run e2e tests
run: |
cd frontend
npx playwright test e2e/journeys/
env:
API_URL: http://localhost:8080/api/v1
- name: Upload test results
uses: actions/upload-artifact@v4
if: failure()
with:
name: playwright-report
path: frontend/playwright-report/
Before merging:
Always include references in test file header:
/**
* Journey: First Time User Creates Audit
* Source: specs/journeys/first-time-user.md
* Map: specs/journey-maps/first-time-user.map.yaml
* Stories:
* - specs/stories/audits/create-first-audit.md
* - specs/stories/onboarding/welcome-dashboard.md
* Features:
* - specs/features/audits/create-audit.feature
* - specs/features/dashboard/empty-state.feature
*/
This creates the full chain:
Persona → Journey → Story → Feature → Contract → Map → E2E Test
// Wait for API response
const responsePromise = page.waitForResponse(r =>
r.url().includes('/api/audits') && r.request().method() === 'POST'
);
await page.getByTestId('submit').click();
const response = await responsePromise;
expect(response.status()).toBe(201);
// Wait for element
await expect(page.getByTestId('result')).toBeVisible({ timeout: 10000 });
// Wait for navigation
await expect(page).toHaveURL(/\/audits\/[\w-]+/);
// Poll API until condition
await expect(async () => {
const data = await api.get(request, `/audits/${auditId}`);
expect(data.status).toBe('completed');
}).toPass({ timeout: 30000 });
Use this for fields that sometimes clear during rerender/change-detection ticks:
import { expect, type Page } from '@playwright/test';
export const fillStable = async (page: Page, testId: string, value: string) => {
const input = page.getByTestId(testId);
await expect(input).toBeVisible();
await expect(input).toBeEditable();
for (let attempt = 0; attempt < 5; attempt += 1) {
await input.click();
await input.press('ControlOrMeta+A');
await input.fill(value);
if ((await input.inputValue()) !== value) continue;
await page.waitForTimeout(100);
if ((await input.inputValue()) === value) return;
}
await expect.poll(async () => input.inputValue(), { timeout: 10000 }).toBe(value);
};
Use locator.evaluate(...) to set .value only as a last resort.
test('Step 2: Shows error on invalid input', async ({ page }) => {
await page.goto('/audits/new');
// Submit without required field
await page.getByTestId('submit').click();
// Verify error displayed
await expect(page.getByTestId('entity-name-error')).toBeVisible();
await expect(page.getByTestId('entity-name-error')).toContainText('required');
// Verify no navigation occurred
await expect(page).toHaveURL('/audits/new');
});
test('Step 3: Confirms before destructive action', async ({ page }) => {
await page.goto(`/audits/${auditId}`);
// Open confirmation
await page.getByTestId('delete-btn').click();
await expect(page.getByTestId('confirm-dialog')).toBeVisible();
// Dismiss
await page.getByTestId('cancel-btn').click();
await expect(page.getByTestId('confirm-dialog')).toBeHidden();
// Confirm
await page.getByTestId('delete-btn').click();
await page.getByTestId('confirm-btn').click();
// Verify action completed
await expect(page).toHaveURL('/audits');
});
Visual regression tests catch layout issues, text clipping, and rendering defects that functional tests miss. Add these whenever creating pages/components with complex layouts.
Add visual regression tests when:
Add snapshot configuration to playwright.config.ts:
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
// ... existing config ...
// Snapshot configuration for visual regression testing
snapshotPathTemplate: '{testDir}/__screenshots__/{testFilePath}/{arg}{ext}',
expect: {
toHaveScreenshot: {
maxDiffPixelRatio: 0.01, // Allow 1% pixel difference for minor rendering variations
animations: 'disabled',
},
},
});
Create visual tests in the same spec file as journey tests, with Visual: prefix:
test.describe('Journey: {Journey Title}', () => {
// ... functional tests ...
test('Visual: {Page/Component} layout renders correctly', async ({ page }) => {
await page.emulateMedia({ reducedMotion: 'reduce' });
// Set consistent viewport for visual testing
await page.setViewportSize({ width: 1440, height: 900 });
// Disable animations for stable screenshots
await page.addStyleTag({
content: `
*, *::before, *::after {
animation-duration: 0s !important;
animation-delay: 0s !important;
transition-duration: 0s !important;
transition-delay: 0s !important;
}
`,
});
// Navigate to page (follow journey, don't skip auth/setup)
await page.goto('/path');
await expect(page.getByTestId('page-root')).toBeVisible();
// Full page screenshot
await expect(page).toHaveScreenshot('{page}-full.png', {
fullPage: true,
});
// Component-level screenshots for targeted regression detection
const sidebar = page.getByTestId('sidebar');
await expect(sidebar).toBeVisible();
await expect(sidebar).toHaveScreenshot('{page}-sidebar.png');
});
});
Beyond pixel comparison, add assertion-based tests that catch common layout defects:
test('Visual: {Component} text is not clipped or truncated', async ({ page }) => {
await page.setViewportSize({ width: 1440, height: 900 });
await page.goto('/path');
const component = page.getByTestId('component');
await expect(component).toBeVisible();
// Verify key text content is fully visible (fails if truncated)
await expect(component.getByText('Full Title Text')).toBeVisible();
await expect(component.getByText(/Expected description text/i)).toBeVisible();
// Verify minimum dimensions (catches grid/flex collapse issues)
const box = await component.boundingBox();
expect(box).not.toBeNull();
expect(box!.width).toBeGreaterThanOrEqual(280); // Minimum readable width
expect(box!.height).toBeGreaterThanOrEqual(100); // Minimum content height
});
Test critical breakpoints:
const viewports = [
{ name: 'mobile', width: 375, height: 667 },
{ name: 'tablet', width: 768, height: 1024 },
{ name: 'desktop', width: 1440, height: 900 },
];
for (const vp of viewports) {
test(`Visual: {Page} renders correctly on ${vp.name}`, async ({ page }) => {
await page.setViewportSize({ width: vp.width, height: vp.height });
await page.goto('/path');
await expect(page).toHaveScreenshot(`{page}-${vp.name}.png`, { fullPage: true });
});
}
Angular components render with a host element (<app-component>) that defaults to display: block. This breaks CSS Grid child positioning (e.g., lg:col-span-4 won't work).
Fix: Add display: contents to the host:
@Component({
selector: 'app-sidebar',
standalone: true,
templateUrl: './sidebar.component.html',
// Fix: Makes host "invisible" to grid, allowing inner grid classes to work
host: { style: 'display: contents' }
})
export class SidebarComponent {}
Test for this defect:
test('Visual: Sidebar has correct grid width', async ({ page }) => {
const sidebar = page.getByTestId('sidebar');
const box = await sidebar.boundingBox();
// Should be ~33% of 12-col grid (col-span-4), not collapsed to content width
expect(box!.width).toBeGreaterThanOrEqual(280);
});
Visual: for easy filteringreducedMotionfullPage: true only when necessary for stabilitye2e/__screenshots__/ (gitignored by default)--update-snapshots after verifying correct renderingBefore merging visual tests:
Visual:npx claudepluginhub slusset/intention-driven-design --plugin idd-skillsProvides behavioral guidelines to reduce common LLM coding mistakes, focusing on simplicity, surgical changes, assumption surfacing, and verifiable success criteria.
Searches, retrieves, and installs Agent Skills from prompts.chat registry using MCP tools like search_skills and get_skill. Activates for finding skills, browsing catalogs, or extending Claude.
Creates, edits, and optimizes skills for Claude Code, including drafting, evaluating with test prompts, iterating on performance, and improving skill descriptions for better triggering accuracy.