Tutorials
Screenshot-Based Visual Testing
Build a custom validator that renders your blocks, captures screenshots, and uses AI to verify visual consistency. This demonstrates the power of custom validators that can literally validate anything.
Prerequisites
- Completed the First Block tutorial
- Node.js 20+
- Playwright installed (
npm install playwright) - 30 minutes
What You'll Build
A custom visual.screenshot validator that:
- Renders block output in a headless browser
- Captures a screenshot
- Uses AI to check contrast, accessibility, and design consistency
- Reports issues with visual evidence
Step 1: Project Setup
Start with an existing blocks project or create one:
mkdir visual-blocks && cd visual-blocks
npm init -y
npm install playwright @blocksai/cli
npx playwright install chromiumCreate the structure:
mkdir -p blocks/card-component validatorsStep 2: Create a Component Block
Create blocks/card-component/block.ts:
export interface CardInput {
title: string;
description: string;
variant: 'light' | 'dark';
}
export interface CardOutput {
html: string;
}
export function render(input: CardInput): CardOutput {
const { title, description, variant } = input;
const styles = variant === 'dark'
? 'background: #1a1a1a; color: #ffffff;'
: 'background: #ffffff; color: #1a1a1a;';
return {
html: `
<div style="${styles} padding: 24px; border-radius: 8px; max-width: 300px; font-family: system-ui;">
<h2 style="margin: 0 0 12px 0; font-size: 18px;">${title}</h2>
<p style="margin: 0; font-size: 14px; opacity: 0.8;">${description}</p>
</div>
`,
};
}Create blocks/card-component/index.ts:
export { render } from './block';
export type { CardInput, CardOutput } from './block';Step 3: Build the Custom Validator
Create validators/visual-screenshot.ts:
import { chromium } from 'playwright';
import { readFileSync, writeFileSync, mkdirSync } from 'fs';
import { join } from 'path';
import OpenAI from 'openai';
interface ValidatorContext {
blockName: string;
blockPath: string;
config: any;
}
interface ValidationIssue {
type: 'error' | 'warning' | 'info';
code: string;
message: string;
file?: string;
}
interface ValidationResult {
valid: boolean;
issues: ValidationIssue[];
context?: {
screenshotPath?: string;
analysis?: string;
};
}
export const id = 'visual.screenshot';
export async function validate(ctx: ValidatorContext): Promise<ValidationResult> {
const issues: ValidationIssue[] = [];
// 1. Import and render the block
const blockModule = await import(join(process.cwd(), ctx.blockPath, 'index.ts'));
const renderFn = blockModule.render || blockModule.default;
if (!renderFn) {
return {
valid: false,
issues: [{
type: 'error',
code: 'NO_RENDER_FUNCTION',
message: 'Block must export a render function',
}],
};
}
// 2. Render with test data
const testData = {
title: 'Test Card Title',
description: 'This is a test description for visual validation.',
variant: 'dark',
};
const output = renderFn(testData);
const html = output.html;
// 3. Capture screenshot with Playwright
const browser = await chromium.launch();
const page = await browser.newPage();
await page.setContent(`
<!DOCTYPE html>
<html>
<head>
<style>
body {
margin: 0;
padding: 40px;
background: #f0f0f0;
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
}
</style>
</head>
<body>${html}</body>
</html>
`);
// Ensure screenshots directory exists
const screenshotsDir = join(process.cwd(), '.blocks', 'screenshots');
mkdirSync(screenshotsDir, { recursive: true });
const screenshotPath = join(screenshotsDir, `${ctx.blockName.replace(/\./g, '-')}.png`);
await page.screenshot({ path: screenshotPath, fullPage: true });
await browser.close();
// 4. Analyze with AI
const openai = new OpenAI();
const imageBuffer = readFileSync(screenshotPath);
const base64Image = imageBuffer.toString('base64');
const response = await openai.chat.completions.create({
model: 'gpt-4o',
messages: [
{
role: 'user',
content: [
{
type: 'text',
text: `Analyze this UI component screenshot for visual quality issues.
Check for:
1. Color contrast (WCAG AA compliance - 4.5:1 for text)
2. Text readability (font size, spacing)
3. Visual hierarchy (clear title vs description)
4. Consistency (padding, alignment)
5. Accessibility concerns
Respond with JSON:
{
"passed": boolean,
"contrastRatio": "estimated ratio",
"issues": [
{ "severity": "error|warning", "description": "..." }
],
"summary": "one sentence summary"
}`,
},
{
type: 'image_url',
image_url: {
url: `data:image/png;base64,${base64Image}`,
},
},
],
},
],
max_tokens: 500,
});
const analysisText = response.choices[0]?.message?.content || '{}';
// Parse AI response
let analysis;
try {
// Extract JSON from response
const jsonMatch = analysisText.match(/\{[\s\S]*\}/);
analysis = jsonMatch ? JSON.parse(jsonMatch[0]) : { passed: true, issues: [] };
} catch {
analysis = { passed: true, issues: [], summary: analysisText };
}
// 5. Convert AI findings to validation issues
if (!analysis.passed) {
for (const issue of analysis.issues || []) {
issues.push({
type: issue.severity === 'error' ? 'error' : 'warning',
code: 'VISUAL_ISSUE',
message: issue.description,
});
}
}
return {
valid: issues.filter(i => i.type === 'error').length === 0,
issues,
context: {
screenshotPath,
analysis: analysis.summary,
},
};
}Step 4: Configure blocks.yml
Update your blocks.yml to use the custom validator:
$schema: "blocks/v2"
name: "Visual Components"
philosophy:
- "All components must meet WCAG AA contrast requirements"
- "Text must be readable at standard viewing distances"
- "Visual hierarchy should be clear and consistent"
domain:
entities:
card:
fields: [title, description, variant]
semantics:
accessible_html:
description: "HTML that meets accessibility standards"
ai:
provider: openai
model: gpt-4o-mini
validators:
- schema
- shape
- name: visual.screenshot
run: validators/visual-screenshot.ts
blocks:
ui.card:
description: "A card component for displaying content"
path: blocks/card-component
inputs:
- name: card
type: entity.card
outputs:
- name: html
type: string
semantics: [accessible_html]Step 5: Run Visual Validation
blocks run ui.cardExample output:
✓ ui.card
✓ schema.io
✓ shape.exports.ts
✓ visual.screenshot
Screenshot saved: .blocks/screenshots/ui-card.png
Analysis: Component has good contrast and clear hierarchy.
All blocks passed validation.Step 6: Test with Poor Contrast
Edit your block to use low contrast colors:
const styles = variant === 'dark'
? 'background: #333333; color: #555555;' // Poor contrast!
: 'background: #ffffff; color: #cccccc;'; // Also poor!Run validation:
blocks run ui.card✗ ui.card
✓ schema.io
✓ shape.exports.ts
✗ visual.screenshot
Screenshot saved: .blocks/screenshots/ui-card.png
VISUAL_ISSUE: Text contrast ratio (~1.8:1) fails WCAG AA
requirement of 4.5:1. The gray text (#555555) on dark
background (#333333) is difficult to read.
VISUAL_ISSUE: Description text opacity makes it nearly
invisible against the background.Key Concepts
Custom Validators Can Do Anything
This tutorial demonstrates that validators aren't limited to static analysis:
- Render output with real browsers
- Capture screenshots for visual evidence
- Use AI vision to analyze images
- Check accessibility with real contrast calculations
Validation Context
Your validator receives:
blockName- The block being validatedblockPath- Path to block filesconfig- Full blocks.yml configuration
Rich Results
Return detailed context for debugging:
- Screenshot paths for evidence
- AI analysis summaries
- Specific, actionable issues