070959e133
landing-page-staging / Deploy landing page to staging (push) Has been skipped
landing-page-ci / Validate landing page (push) Failing after 4s
visual-baseline / Capture visual baselines (push) Has been cancelled
bake-plugin-previews / Bake plugin previews (push) Has been cancelled
950 lines
30 KiB
TypeScript
950 lines
30 KiB
TypeScript
import { expect } from '@playwright/test';
|
|
import type { Locator, Page, Route } from '@playwright/test';
|
|
import { mkdir } from 'node:fs/promises';
|
|
import path from 'node:path';
|
|
import { fulfillAgentsRoute } from './mock-factory.js';
|
|
import { T } from '@/timeouts';
|
|
|
|
const STORAGE_KEY = 'open-design:config';
|
|
const GITHUB_STARS_STORAGE_KEY = 'open-design:gh-stars';
|
|
const VISUAL_STABILITY_STORAGE_KEY = 'open-design:visual-stability';
|
|
const VISUAL_STYLE_ID = 'od-visual-stability-style';
|
|
// Keep this exact-route mock narrow so unrelated GitHub UI still behaves normally.
|
|
const VISUAL_GITHUB_REPO_API = 'https://api.github.com/repos/nexu-io/open-design';
|
|
const VISUAL_GITHUB_STARS = 40_000;
|
|
|
|
type VisualConfig = {
|
|
mode: 'daemon' | 'api';
|
|
apiKey: string;
|
|
baseUrl: string;
|
|
model: string;
|
|
apiProtocol?: 'anthropic' | 'openai' | 'azure' | 'google' | 'ollama' | 'senseaudio' | 'aihubmix';
|
|
agentId: string | null;
|
|
skillId: string | null;
|
|
designSystemId: string | null;
|
|
onboardingCompleted: boolean;
|
|
agentModels: Record<string, { model?: string; reasoning?: string }>;
|
|
agentCliEnv?: Record<string, Record<string, string>>;
|
|
privacyDecisionAt: number | null;
|
|
telemetry: { metrics?: boolean; content?: boolean; artifactManifest?: boolean };
|
|
};
|
|
|
|
const VISUAL_CONFIG = {
|
|
mode: 'daemon',
|
|
apiKey: '',
|
|
baseUrl: 'https://api.anthropic.com',
|
|
model: 'claude-sonnet-4-5',
|
|
agentId: 'mock',
|
|
skillId: null,
|
|
designSystemId: null,
|
|
onboardingCompleted: true,
|
|
agentModels: {},
|
|
privacyDecisionAt: 1,
|
|
telemetry: { metrics: false, content: false, artifactManifest: false },
|
|
} satisfies VisualConfig;
|
|
|
|
const visualStableTimeoutMs = 10_000;
|
|
const visualStableFrameCount = 3;
|
|
|
|
function waitForVisualTimeout(): Promise<void> {
|
|
return new Promise((resolve) => {
|
|
setTimeout(resolve, visualStableTimeoutMs);
|
|
});
|
|
}
|
|
|
|
const MOCK_AGENT = {
|
|
id: 'mock',
|
|
name: 'Mock Agent',
|
|
bin: 'mock-agent',
|
|
available: true,
|
|
version: 'test',
|
|
models: [{ id: 'default', label: 'Default' }],
|
|
} as const;
|
|
|
|
export const VISUAL_CLI_AGENTS = [
|
|
{
|
|
id: 'claude',
|
|
name: 'Claude Code',
|
|
bin: 'claude',
|
|
available: true,
|
|
version: '2.1.31',
|
|
models: [
|
|
{ id: 'default', label: 'Default (CLI config)' },
|
|
{ id: 'sonnet-alias', label: 'Sonnet (alias)' },
|
|
{ id: 'opus-alias', label: 'Opus (alias)' },
|
|
{ id: 'haiku-alias', label: 'Haiku (alias)' },
|
|
{ id: 'sonnet-nightly', label: 'Sonnet Nightly' },
|
|
{ id: 'opus-nightly', label: 'Opus Nightly' },
|
|
{ id: 'sonnet-4.5', label: 'Sonnet 4.5' },
|
|
{ id: 'opus-4.5', label: 'Opus 4.5' },
|
|
],
|
|
},
|
|
{
|
|
id: 'codex',
|
|
name: 'Codex CLI',
|
|
bin: 'codex',
|
|
available: true,
|
|
version: '0.134.0',
|
|
models: [
|
|
{ id: 'default', label: 'Default (CLI config)' },
|
|
{ id: 'gpt-5.4', label: 'GPT-5.4' },
|
|
{ id: 'gpt-5.4-mini', label: 'GPT-5.4-Mini' },
|
|
{ id: 'gpt-5.3-codex-spark', label: 'GPT-5.3-Codex-Spark' },
|
|
{ id: 'gpt-5.3', label: 'GPT-5.3' },
|
|
{ id: 'gpt-5.2', label: 'GPT-5.2' },
|
|
],
|
|
},
|
|
] as const;
|
|
|
|
export const VISUAL_AMR_AGENT = {
|
|
id: 'amr',
|
|
name: 'Open Design',
|
|
bin: 'vela',
|
|
available: true,
|
|
version: '0.1.0',
|
|
models: [
|
|
{ id: 'deepseek-v4-flash', label: 'DeepSeek V4 Flash' },
|
|
{ id: 'deepseek-v3.2', label: 'DeepSeek V3.2' },
|
|
{ id: 'glm-5.1', label: 'GLM 5.1' },
|
|
{ id: 'gemini-2.5-flash', label: 'Gemini 2.5 Flash' },
|
|
],
|
|
} as const;
|
|
|
|
const VISUAL_PROJECTS = [
|
|
{
|
|
id: 'visual-project-launchpad',
|
|
name: 'Launchpad dashboard',
|
|
skillId: null,
|
|
designSystemId: 'agentic',
|
|
createdAt: 1_700_000_000_000,
|
|
updatedAt: 1_700_000_050_000,
|
|
metadata: { kind: 'prototype' },
|
|
status: { label: 'Ready', tone: 'success' },
|
|
},
|
|
{
|
|
id: 'visual-project-brand-kit',
|
|
name: 'Brand kit refresh',
|
|
skillId: null,
|
|
designSystemId: 'airbnb',
|
|
createdAt: 1_700_000_100_000,
|
|
updatedAt: 1_700_000_150_000,
|
|
metadata: { kind: 'image' },
|
|
status: { label: 'Needs input', tone: 'warning' },
|
|
},
|
|
] as const;
|
|
|
|
type VisualProject = (typeof VISUAL_PROJECTS)[number];
|
|
|
|
const VISUAL_PROJECT_FILE_HTML =
|
|
'<!doctype html><html><body><main><h1>Visual CSS Smoke</h1><p>Workspace preview remains framed.</p></main></body></html>';
|
|
|
|
const VISUAL_PROJECT_FILES = [
|
|
{
|
|
name: 'index.html',
|
|
path: 'index.html',
|
|
type: 'file',
|
|
size: VISUAL_PROJECT_FILE_HTML.length,
|
|
mtime: 1_700_000_200_000,
|
|
kind: 'html',
|
|
mime: 'text/html; charset=utf-8',
|
|
artifactKind: 'html',
|
|
artifactManifest: {
|
|
version: 1,
|
|
kind: 'html',
|
|
title: 'Visual CSS Smoke',
|
|
entry: 'index.html',
|
|
renderer: 'html',
|
|
status: 'complete',
|
|
exports: ['html', 'pdf', 'zip'],
|
|
primary: true,
|
|
createdAt: '2026-06-15T00:00:00.000Z',
|
|
updatedAt: '2026-06-15T00:00:00.000Z',
|
|
metadata: { identifier: 'visual-css-smoke', artifactType: 'text/html', inferred: false },
|
|
},
|
|
},
|
|
] as const;
|
|
|
|
type VisualPageOptions = {
|
|
projects?: readonly VisualProject[];
|
|
config?: Partial<VisualConfig>;
|
|
agents?: readonly unknown[];
|
|
};
|
|
|
|
type VisualVelaAccountOptions = {
|
|
profile?: string;
|
|
plan?: string;
|
|
balanceUsd?: string;
|
|
email?: string;
|
|
};
|
|
|
|
const VISUAL_PLUGINS = [
|
|
makeVisualPlugin({
|
|
id: 'visual-prototype-starter',
|
|
title: 'Prototype Starter',
|
|
description: 'Create polished product prototypes from a short brief.',
|
|
mode: 'prototype',
|
|
taskKind: 'new-generation',
|
|
featured: true,
|
|
tags: ['web', 'prototype'],
|
|
query: 'Design a {{topic}} prototype.',
|
|
}),
|
|
makeVisualPlugin({
|
|
id: 'visual-deck-writer',
|
|
title: 'Deck Writer',
|
|
description: 'Turn strategy notes into a presentation deck.',
|
|
mode: 'deck',
|
|
taskKind: 'new-generation',
|
|
tags: ['slides'],
|
|
query: 'Draft a {{topic}} deck.',
|
|
previewEntry: 'preview.html',
|
|
}),
|
|
makeVisualPlugin({
|
|
id: 'visual-video-storyboard',
|
|
title: 'Video Storyboard',
|
|
description: 'Storyboard a video concept with scenes and voiceover beats.',
|
|
mode: 'video',
|
|
taskKind: 'new-generation',
|
|
featured: true,
|
|
tags: ['video'],
|
|
query: 'Create a {{topic}} video storyboard.',
|
|
}),
|
|
makeVisualPlugin({
|
|
id: 'visual-figma-importer',
|
|
title: 'Figma Importer',
|
|
description: 'Migrate a Figma frame into an editable Open Design project.',
|
|
mode: 'prototype',
|
|
taskKind: 'figma-migration',
|
|
tags: ['migration'],
|
|
}),
|
|
] as const;
|
|
|
|
const VISUAL_DESIGN_SYSTEMS = [
|
|
{
|
|
id: 'agentic',
|
|
title: 'Agentic',
|
|
category: 'Productivity & SaaS',
|
|
summary: 'Conversational AI-first interface with minimal controls.',
|
|
surface: 'web',
|
|
swatches: ['#ff5a1f', '#111827'],
|
|
},
|
|
{
|
|
id: 'airbnb',
|
|
title: 'Airbnb',
|
|
category: 'E-Commerce & Retail',
|
|
summary: 'Travel marketplace with warm coral accents.',
|
|
surface: 'web',
|
|
swatches: ['#a3165b', '#ff385c'],
|
|
},
|
|
{
|
|
id: 'motion-poster',
|
|
title: 'Motion Poster',
|
|
category: 'Design & Creative',
|
|
summary: 'Motion-first visual system for video concepts.',
|
|
surface: 'video',
|
|
swatches: ['#111827', '#38bdf8'],
|
|
},
|
|
] as const;
|
|
|
|
export async function configureVisualPage(page: Page, options: VisualPageOptions = {}): Promise<void> {
|
|
const projects = options.projects ?? VISUAL_PROJECTS;
|
|
const config = { ...VISUAL_CONFIG, ...(options.config ?? {}) };
|
|
const agents = options.agents ?? [MOCK_AGENT];
|
|
|
|
await page.addInitScript(([key, config, githubStarsKey, githubStarsCount, visualStabilityKey]) => {
|
|
window.localStorage.setItem(key, JSON.stringify(config));
|
|
window.localStorage.setItem(
|
|
githubStarsKey,
|
|
JSON.stringify({ count: githubStarsCount, ts: Date.now() }),
|
|
);
|
|
window.localStorage.setItem(visualStabilityKey, '1');
|
|
}, [STORAGE_KEY, config, GITHUB_STARS_STORAGE_KEY, VISUAL_GITHUB_STARS, VISUAL_STABILITY_STORAGE_KEY] as const);
|
|
|
|
await page.route('**/api/app-config', async (route) => {
|
|
await fulfillGet(route, { config });
|
|
});
|
|
|
|
await page.route('**/api/agents**', async (route) => {
|
|
await fulfillAgentsRoute(route, agents);
|
|
});
|
|
|
|
await page.route('**/api/test/connection', async (route) => {
|
|
if (route.request().method() !== 'POST') {
|
|
await route.continue();
|
|
return;
|
|
}
|
|
|
|
await route.fulfill({
|
|
json: {
|
|
ok: true,
|
|
kind: 'success',
|
|
latencyMs: 1,
|
|
model: config.model,
|
|
sample: 'Visual connection check.',
|
|
},
|
|
});
|
|
});
|
|
|
|
await page.route('**/api/provider/models', async (route) => {
|
|
if (route.request().method() !== 'POST') {
|
|
await route.continue();
|
|
return;
|
|
}
|
|
|
|
await route.fulfill({
|
|
json: {
|
|
ok: true,
|
|
kind: 'success',
|
|
latencyMs: 1,
|
|
models: [
|
|
{ id: 'gpt-4o', label: 'GPT-4o' },
|
|
{ id: 'gpt-4o-mini', label: 'GPT-4o mini' },
|
|
{ id: 'claude-sonnet-4-5', label: 'Claude Sonnet 4.5' },
|
|
],
|
|
},
|
|
});
|
|
});
|
|
|
|
await page.route('**/api/health', async (route) => {
|
|
await fulfillGet(route, { ok: true });
|
|
});
|
|
|
|
await page.route('**/api/community/discord', async (route) => {
|
|
await fulfillGet(route, {
|
|
onlineCount: 0,
|
|
memberCount: 0,
|
|
});
|
|
});
|
|
|
|
await page.route('**/api/integrations/vela/status', async (route) => {
|
|
await fulfillGet(route, {
|
|
loggedIn: false,
|
|
profile: 'local',
|
|
configPath: '/tmp/.amr/config.json',
|
|
user: null,
|
|
});
|
|
});
|
|
|
|
await page.route('**/api/media/providers/aihubmix/models**', async (route) => {
|
|
await fulfillGet(route, { models: [] });
|
|
});
|
|
|
|
await page.route(VISUAL_GITHUB_REPO_API, async (route) => {
|
|
if (route.request().method() !== 'GET') {
|
|
await route.continue();
|
|
return;
|
|
}
|
|
|
|
await route.fulfill({
|
|
json: { stargazers_count: VISUAL_GITHUB_STARS },
|
|
});
|
|
});
|
|
|
|
await page.route('**/api/projects', async (route) => {
|
|
await fulfillGet(route, { projects });
|
|
});
|
|
|
|
await page.route('**/api/projects/*/files', async (route) => {
|
|
if (route.request().method() !== 'GET') {
|
|
await route.continue();
|
|
return;
|
|
}
|
|
await fulfillGet(route, { files: VISUAL_PROJECT_FILES });
|
|
});
|
|
|
|
await page.route('**/api/projects/*/raw/*', async (route) => {
|
|
if (route.request().method() !== 'GET') {
|
|
await route.continue();
|
|
return;
|
|
}
|
|
await route.fulfill({
|
|
contentType: 'text/html; charset=utf-8',
|
|
body: VISUAL_PROJECT_FILE_HTML,
|
|
});
|
|
});
|
|
|
|
await page.route('**/api/projects/*/upload', async (route) => {
|
|
if (route.request().method() !== 'POST') {
|
|
await route.continue();
|
|
return;
|
|
}
|
|
await route.fulfill({
|
|
json: {
|
|
files: [
|
|
{
|
|
name: 'visual-reference.txt',
|
|
originalName: 'visual-reference.txt',
|
|
path: 'visual-reference.txt',
|
|
size: 55,
|
|
},
|
|
],
|
|
},
|
|
});
|
|
});
|
|
|
|
await page.route('**/api/routines', async (route) => {
|
|
await fulfillGet(route, { routines: [] });
|
|
});
|
|
|
|
await page.route('**/api/automation-templates', async (route) => {
|
|
await fulfillGet(route, { templates: [] });
|
|
});
|
|
|
|
await page.route('**/api/automation-proposals?status=pending-review', async (route) => {
|
|
await fulfillGet(route, { proposals: [] });
|
|
});
|
|
|
|
await page.route('**/api/automation-source-packets?limit=3', async (route) => {
|
|
await fulfillGet(route, { packets: [] });
|
|
});
|
|
|
|
await page.route('**/api/plugins', async (route) => {
|
|
await fulfillGet(route, { plugins: VISUAL_PLUGINS });
|
|
});
|
|
|
|
await page.route('**/api/plugins/*/preview', async (route) => {
|
|
const id = decodeURIComponent(new URL(route.request().url()).pathname.split('/').at(-2) ?? 'plugin');
|
|
await route.fulfill({
|
|
contentType: 'text/html',
|
|
body: `<!doctype html><html><body><main><h1>${escapeHtml(id)} preview</h1></main></body></html>`,
|
|
});
|
|
});
|
|
|
|
await page.route('**/api/plugins/*/apply', async (route) => {
|
|
if (route.request().method() !== 'POST') {
|
|
await route.continue();
|
|
return;
|
|
}
|
|
const id = decodeURIComponent(new URL(route.request().url()).pathname.split('/').at(-2) ?? 'plugin');
|
|
await route.fulfill({
|
|
json: {
|
|
ok: true,
|
|
query: `Design a ${id} concept.`,
|
|
contextItems: [],
|
|
inputs: [],
|
|
assets: [],
|
|
mcpServers: [],
|
|
projectMetadata: {},
|
|
trust: 'trusted',
|
|
capabilitiesGranted: ['prompt:inject'],
|
|
capabilitiesRequired: ['prompt:inject'],
|
|
appliedPlugin: {
|
|
snapshotId: `visual-snapshot-${id}`,
|
|
pluginId: id,
|
|
pluginVersion: '1.0.0',
|
|
manifestSourceDigest: 'a'.repeat(64),
|
|
inputs: {},
|
|
resolvedContext: { items: [] },
|
|
capabilitiesGranted: ['prompt:inject'],
|
|
capabilitiesRequired: ['prompt:inject'],
|
|
assetsStaged: [],
|
|
taskKind: 'new-generation',
|
|
appliedAt: 0,
|
|
connectorsRequired: [],
|
|
connectorsResolved: [],
|
|
mcpServers: [],
|
|
status: 'fresh',
|
|
},
|
|
},
|
|
});
|
|
});
|
|
|
|
await page.route('**/api/marketplaces', async (route) => {
|
|
await fulfillGet(route, { marketplaces: [] });
|
|
});
|
|
|
|
await page.route('**/api/design-systems', async (route) => {
|
|
await fulfillGet(route, { designSystems: VISUAL_DESIGN_SYSTEMS });
|
|
});
|
|
|
|
await page.route('**/api/design-systems/*', async (route) => {
|
|
if (route.request().method() !== 'GET') {
|
|
await route.continue();
|
|
return;
|
|
}
|
|
const id = decodeURIComponent(new URL(route.request().url()).pathname.split('/').at(-1) ?? 'agentic');
|
|
const system = VISUAL_DESIGN_SYSTEMS.find((item) => item.id === id) ?? VISUAL_DESIGN_SYSTEMS[0];
|
|
await route.fulfill({
|
|
json: {
|
|
designSystem: {
|
|
...system,
|
|
body: `# ${system.title}\n\nDesign guidance for ${system.title}.`,
|
|
},
|
|
},
|
|
});
|
|
});
|
|
|
|
await page.route('**/api/design-systems/*/showcase', async (route) => {
|
|
const id = decodeURIComponent(new URL(route.request().url()).pathname.split('/').at(-2) ?? 'design-system');
|
|
await route.fulfill({
|
|
contentType: 'text/html',
|
|
body: `<!doctype html><html><body><main><h1>${escapeHtml(id)} showcase</h1></main></body></html>`,
|
|
});
|
|
});
|
|
|
|
await page.route('**/api/design-systems/*/preview', async (route) => {
|
|
const id = decodeURIComponent(new URL(route.request().url()).pathname.split('/').at(-2) ?? 'design-system');
|
|
await route.fulfill({
|
|
contentType: 'text/html',
|
|
body: `<!doctype html><html><body><main><h1>${escapeHtml(id)} tokens</h1></main></body></html>`,
|
|
});
|
|
});
|
|
|
|
await page.route('**/api/skills', async (route) => {
|
|
await fulfillGet(route, { skills: [] });
|
|
});
|
|
|
|
await page.route('**/api/design-templates', async (route) => {
|
|
await fulfillGet(route, { designTemplates: [] });
|
|
});
|
|
|
|
await page.route('**/api/prompt-templates', async (route) => {
|
|
await fulfillGet(route, { promptTemplates: [] });
|
|
});
|
|
|
|
await page.route('**/api/connectors', async (route) => {
|
|
await fulfillGet(route, { connectors: [] });
|
|
});
|
|
|
|
await page.route('**/api/mcp/servers', async (route) => {
|
|
await fulfillGet(route, { servers: [], templates: [] });
|
|
});
|
|
|
|
await page.addInitScript(([styleId]) => {
|
|
const installStabilityStyle = () => {
|
|
if (document.getElementById(styleId) != null) return;
|
|
const style = document.createElement('style');
|
|
style.id = styleId;
|
|
style.textContent = `
|
|
*, *::before, *::after {
|
|
animation: none !important;
|
|
transition: none !important;
|
|
caret-color: transparent !important;
|
|
scroll-behavior: auto !important;
|
|
overflow-anchor: none !important;
|
|
}
|
|
`;
|
|
(document.head ?? document.documentElement).appendChild(style);
|
|
};
|
|
|
|
installStabilityStyle();
|
|
document.addEventListener('DOMContentLoaded', installStabilityStyle, { once: true });
|
|
}, [VISUAL_STYLE_ID] as const);
|
|
}
|
|
|
|
export async function mockSignedInVelaAccount(
|
|
page: Page,
|
|
options: VisualVelaAccountOptions = {},
|
|
): Promise<void> {
|
|
const profile = options.profile ?? 'test';
|
|
const plan = options.plan ?? 'plus';
|
|
const balanceUsd = options.balanceUsd ?? '247.51';
|
|
const email = options.email ?? 'leaf@example.com';
|
|
const fetchedAt = '2026-06-25T03:59:00.000Z';
|
|
|
|
await page.route('**/api/integrations/vela/status', async (route) => {
|
|
await route.fulfill({
|
|
status: 200,
|
|
contentType: 'application/json',
|
|
body: JSON.stringify({
|
|
loggedIn: true,
|
|
loginInFlight: false,
|
|
profile,
|
|
user: { id: 'u1', email },
|
|
account: { plan, balanceUsd },
|
|
configPath: '/home/test/.amr/config.json',
|
|
}),
|
|
});
|
|
});
|
|
|
|
await page.route('**/api/integrations/vela/wallet**', async (route) => {
|
|
await route.fulfill({
|
|
status: 200,
|
|
contentType: 'application/json',
|
|
body: JSON.stringify({
|
|
status: 'available',
|
|
profile,
|
|
user: { id: 'u1', email, plan },
|
|
balanceUsd,
|
|
updatedAt: fetchedAt,
|
|
fetchedAt,
|
|
stale: false,
|
|
source: 'vela_api',
|
|
}),
|
|
});
|
|
});
|
|
}
|
|
|
|
export async function waitForVisualReady(page: Page): Promise<void> {
|
|
await page.getByText('Loading Open Design…').waitFor({ state: 'hidden', timeout: T.long });
|
|
await expect(page.getByTestId('home-hero')).toBeVisible({ timeout: T.medium });
|
|
await expect(page.getByTestId('home-hero-input')).toBeVisible({ timeout: T.medium });
|
|
await page.evaluate(async () => {
|
|
await document.fonts.ready;
|
|
});
|
|
}
|
|
|
|
export async function waitForVisualProjects(page: Page, projects: readonly VisualProject[]): Promise<void> {
|
|
if (projects.length === 0) {
|
|
await expect(page.getByTestId('recent-projects-strip')).toHaveCount(0);
|
|
return;
|
|
}
|
|
|
|
await expect(page.getByText(projects[0]?.name ?? '')).toBeVisible();
|
|
}
|
|
|
|
export async function gotoVisualHome(page: Page): Promise<void> {
|
|
await page.goto('/', { waitUntil: 'domcontentloaded' });
|
|
await waitForVisualReady(page);
|
|
}
|
|
|
|
export async function gotoVisualWorkspace(page: Page): Promise<void> {
|
|
await page.goto('/projects/visual-project-launchpad', { waitUntil: 'domcontentloaded' });
|
|
await page.getByText('Loading Open Design…').waitFor({ state: 'hidden', timeout: T.long });
|
|
await expect(page).toHaveURL(/\/projects\/visual-project-launchpad/, { timeout: T.medium });
|
|
await expect(page.getByTestId('chat-composer')).toBeVisible({ timeout: T.medium });
|
|
await expect(page.getByTestId('chat-composer-input')).toBeVisible({ timeout: T.medium });
|
|
await expect(page.getByTestId('file-workspace')).toBeVisible({ timeout: T.medium });
|
|
await prepareVisualWorkspaceFileList(page);
|
|
}
|
|
|
|
export async function prepareVisualWorkspaceFileList(page: Page): Promise<void> {
|
|
const fileRow = page.getByTestId('design-file-row-index.html');
|
|
if (!(await fileRow.isVisible().catch(() => false))) {
|
|
await page.getByTestId('workspace-pages-menu-trigger').click();
|
|
await page.getByRole('menuitem', { name: 'All project files' }).click();
|
|
}
|
|
await expect(page.getByTestId('workspace-pages-menu-trigger')).toContainText('All project files');
|
|
await expect(page.getByTestId('design-file-row-index.html')).toBeVisible();
|
|
await expect(page.getByTestId('design-file-preview')).toHaveCount(0);
|
|
await resetVisualScroll(page);
|
|
await waitForVisualStable(page);
|
|
}
|
|
|
|
export async function prepareVisualWorkspacePreview(page: Page): Promise<void> {
|
|
await prepareVisualWorkspaceFileList(page);
|
|
const fileRow = page.getByTestId('design-file-row-index.html');
|
|
await fileRow.getByRole('button').first().click();
|
|
const preview = page.getByTestId('design-file-preview');
|
|
await expect(preview).toBeVisible();
|
|
await preview.getByRole('button', { name: /^Open$/ }).click();
|
|
await expect(
|
|
page.frameLocator('[data-testid="artifact-preview-frame"]').getByRole('heading', {
|
|
name: 'Visual CSS Smoke',
|
|
}),
|
|
).toBeVisible();
|
|
await resetVisualScroll(page);
|
|
await waitForVisualStable(page);
|
|
}
|
|
|
|
export async function prepareVisualAvatarMenu(page: Page): Promise<Locator> {
|
|
await prepareVisualWorkspaceFileList(page);
|
|
const menu = await openAvatarMenu(page);
|
|
await expect(menu.locator('.avatar-item').first()).toBeVisible();
|
|
await expect(page.getByTestId('workspace-pages-menu-trigger')).toContainText('All project files');
|
|
await expect(page.getByTestId('design-file-row-index.html')).toBeVisible();
|
|
await waitForVisualStable(page);
|
|
return menu;
|
|
}
|
|
|
|
export async function prepareVisualSettingsDialog(page: Page): Promise<Locator> {
|
|
await prepareVisualWorkspaceFileList(page);
|
|
const dialog = await openSettingsDetailsFromHeader(page);
|
|
await expect(dialog.getByRole('tablist', { name: 'Execution mode' })).toBeVisible();
|
|
await waitForVisualStable(page);
|
|
return dialog;
|
|
}
|
|
|
|
export async function openAvatarMenu(page: Page): Promise<Locator> {
|
|
await page.locator('.avatar-menu .avatar-agent-trigger').click();
|
|
const menu = page.locator('.avatar-popover[role="dialog"]');
|
|
await expect(menu).toBeVisible();
|
|
return menu;
|
|
}
|
|
|
|
export async function openSettingsDetailsFromHeader(page: Page): Promise<Locator> {
|
|
const settingsTrigger = page.locator('.settings-icon-btn');
|
|
await expect(settingsTrigger).toBeVisible({ timeout: T.medium });
|
|
await settingsTrigger.evaluate((element: HTMLElement) => element.click());
|
|
await expect(page.getByTestId('entry-settings-menu')).toBeVisible({ timeout: T.medium });
|
|
const openDetails = page.getByTestId('entry-settings-open-details');
|
|
await expect(openDetails).toBeVisible({ timeout: T.medium });
|
|
await openDetails.evaluate((element: HTMLElement) => element.click());
|
|
const dialog = page.getByRole('dialog');
|
|
await expect(dialog).toBeVisible({ timeout: T.medium });
|
|
return dialog;
|
|
}
|
|
|
|
export async function waitForVisualFonts(page: Page): Promise<void> {
|
|
await page.evaluate(async () => {
|
|
await document.fonts.ready;
|
|
});
|
|
}
|
|
|
|
export async function resetVisualScroll(page: Page): Promise<void> {
|
|
await page.evaluate(() => {
|
|
document.scrollingElement?.scrollTo({ top: 0, left: 0, behavior: 'instant' });
|
|
for (const element of document.querySelectorAll<HTMLElement>(
|
|
'.entry-main--scroll, .workspace, .file-workspace, [data-testid="file-workspace"]',
|
|
)) {
|
|
element.scrollTo({ top: 0, left: 0, behavior: 'instant' });
|
|
}
|
|
});
|
|
}
|
|
|
|
export async function captureVisual(page: Page, name: string): Promise<string> {
|
|
const outputDir = path.resolve(process.env.OD_VISUAL_OUTPUT_DIR || 'ui/reports/visual-screenshots');
|
|
const safeName = sanitizeVisualName(name);
|
|
const outputPath = path.join(outputDir, `${safeName}.png`);
|
|
await mkdir(outputDir, { recursive: true });
|
|
await waitForVisualStable(page);
|
|
await page.screenshot({ path: outputPath, animations: 'disabled', caret: 'hide' });
|
|
return outputPath;
|
|
}
|
|
|
|
export async function captureVisualTarget(
|
|
page: Page,
|
|
name: string,
|
|
target: Locator | readonly Locator[],
|
|
options: { padding?: number } = {},
|
|
): Promise<string> {
|
|
const outputDir = path.resolve(process.env.OD_VISUAL_OUTPUT_DIR || 'ui/reports/visual-screenshots');
|
|
const safeName = sanitizeVisualName(name);
|
|
const outputPath = path.join(outputDir, `${safeName}.png`);
|
|
const targets = Array.isArray(target) ? target : [target];
|
|
await mkdir(outputDir, { recursive: true });
|
|
await waitForVisualStable(page);
|
|
|
|
const viewport = page.viewportSize();
|
|
if (viewport == null) {
|
|
throw new Error(`Cannot capture visual target ${name}: page has no viewport`);
|
|
}
|
|
|
|
const boxes: Array<{ x: number; y: number; width: number; height: number }> = [];
|
|
for (const locator of targets) {
|
|
await expect(locator).toBeVisible();
|
|
const box = await locator.boundingBox();
|
|
if (box == null || box.width <= 0 || box.height <= 0) {
|
|
throw new Error(`Cannot capture visual target ${name}: locator has no visible bounding box`);
|
|
}
|
|
boxes.push(box);
|
|
}
|
|
|
|
const padding = options.padding ?? 12;
|
|
const minX = Math.max(0, Math.floor(Math.min(...boxes.map((box) => box.x)) - padding));
|
|
const minY = Math.max(0, Math.floor(Math.min(...boxes.map((box) => box.y)) - padding));
|
|
const maxX = Math.min(viewport.width, Math.ceil(Math.max(...boxes.map((box) => box.x + box.width)) + padding));
|
|
const maxY = Math.min(viewport.height, Math.ceil(Math.max(...boxes.map((box) => box.y + box.height)) + padding));
|
|
const width = maxX - minX;
|
|
const height = maxY - minY;
|
|
if (width <= 0 || height <= 0) {
|
|
throw new Error(`Cannot capture visual target ${name}: clipped target is outside the viewport`);
|
|
}
|
|
|
|
await page.screenshot({
|
|
path: outputPath,
|
|
animations: 'disabled',
|
|
caret: 'hide',
|
|
clip: { x: minX, y: minY, width, height },
|
|
});
|
|
return outputPath;
|
|
}
|
|
|
|
export async function scrollVisualLocatorIntoStableView(
|
|
page: Page,
|
|
locator: Locator,
|
|
options: { containerSelector?: string; topOffset?: number } = {},
|
|
): Promise<void> {
|
|
await locator.evaluate(
|
|
(element, { containerSelector, topOffset }) => {
|
|
const target = element as HTMLElement;
|
|
const container = target.closest(containerSelector) as HTMLElement | null;
|
|
if (container != null) {
|
|
const targetRect = target.getBoundingClientRect();
|
|
const containerRect = container.getBoundingClientRect();
|
|
container.scrollTop = Math.max(
|
|
0,
|
|
container.scrollTop + targetRect.top - containerRect.top - topOffset,
|
|
);
|
|
return;
|
|
}
|
|
|
|
const scrollRoot = document.scrollingElement;
|
|
if (scrollRoot == null) return;
|
|
window.scrollTo({
|
|
top: Math.max(0, window.scrollY + target.getBoundingClientRect().top - topOffset),
|
|
behavior: 'instant',
|
|
});
|
|
},
|
|
{
|
|
containerSelector: options.containerSelector ?? '.entry-main--scroll',
|
|
topOffset: options.topOffset ?? 56,
|
|
},
|
|
);
|
|
await waitForVisualStable(page);
|
|
}
|
|
|
|
export async function waitForVisualStable(page: Page): Promise<void> {
|
|
// The app shell owns long-lived SSE channels such as /api/memory/events, so
|
|
// Playwright's networkidle state never represents visual readiness here.
|
|
await waitForVisualFrameAssets(page);
|
|
await waitForVisualLayoutStable(page);
|
|
}
|
|
|
|
async function waitForVisualFrameAssets(page: Page): Promise<void> {
|
|
await Promise.all(
|
|
page.frames().map((frame) =>
|
|
Promise.race([
|
|
frame.evaluate(async () => {
|
|
await document.fonts.ready;
|
|
await Promise.all(
|
|
Array.from(document.images, async (image) => {
|
|
if (image.complete && image.naturalWidth > 0) return;
|
|
await image.decode().catch(() => {});
|
|
}),
|
|
);
|
|
}),
|
|
waitForVisualTimeout(),
|
|
]).catch(() => {}),
|
|
),
|
|
);
|
|
}
|
|
|
|
async function waitForVisualLayoutStable(page: Page): Promise<void> {
|
|
await page.waitForFunction(
|
|
(requiredStableFrames) => new Promise<boolean>((resolve) => {
|
|
let previousSignature = '';
|
|
let stableFrames = 0;
|
|
|
|
const sample = () => {
|
|
const body = document.body;
|
|
const root = document.documentElement;
|
|
const visibleRects = Array.from(document.querySelectorAll('body *'))
|
|
.filter((element) => {
|
|
const style = window.getComputedStyle(element);
|
|
if (style.visibility === 'hidden' || style.display === 'none' || Number(style.opacity) === 0) {
|
|
return false;
|
|
}
|
|
const rect = element.getBoundingClientRect();
|
|
return rect.width > 0 && rect.height > 0;
|
|
})
|
|
.slice(0, 600)
|
|
.map((element) => {
|
|
const rect = element.getBoundingClientRect();
|
|
return [
|
|
element.tagName,
|
|
Math.round(rect.x * 10) / 10,
|
|
Math.round(rect.y * 10) / 10,
|
|
Math.round(rect.width * 10) / 10,
|
|
Math.round(rect.height * 10) / 10,
|
|
].join(':');
|
|
})
|
|
.join('|');
|
|
|
|
const signature = [
|
|
root.clientWidth,
|
|
root.clientHeight,
|
|
root.scrollWidth,
|
|
root.scrollHeight,
|
|
body?.scrollWidth ?? 0,
|
|
body?.scrollHeight ?? 0,
|
|
window.scrollX,
|
|
window.scrollY,
|
|
visibleRects,
|
|
].join('~');
|
|
|
|
if (signature === previousSignature) {
|
|
stableFrames += 1;
|
|
} else {
|
|
previousSignature = signature;
|
|
stableFrames = 0;
|
|
}
|
|
|
|
if (stableFrames >= requiredStableFrames) {
|
|
resolve(true);
|
|
return;
|
|
}
|
|
|
|
window.requestAnimationFrame(sample);
|
|
};
|
|
|
|
window.requestAnimationFrame(sample);
|
|
}),
|
|
visualStableFrameCount,
|
|
{ timeout: visualStableTimeoutMs },
|
|
).catch(() => {});
|
|
}
|
|
|
|
function sanitizeVisualName(name: string): string {
|
|
return name.trim().toLowerCase().replace(/[^a-z0-9-_]+/g, '-').replace(/^-+|-+$/g, '') || 'visual';
|
|
}
|
|
|
|
async function fulfillGet(route: Route, json: unknown): Promise<void> {
|
|
if (route.request().method() !== 'GET') {
|
|
await route.continue();
|
|
return;
|
|
}
|
|
|
|
await route.fulfill({ json });
|
|
}
|
|
|
|
function makeVisualPlugin(input: {
|
|
id: string;
|
|
title: string;
|
|
description: string;
|
|
mode: string;
|
|
taskKind: string;
|
|
featured?: boolean;
|
|
tags?: string[];
|
|
query?: string;
|
|
previewEntry?: string;
|
|
}) {
|
|
return {
|
|
id: input.id,
|
|
title: input.title,
|
|
version: '1.0.0',
|
|
trust: 'trusted',
|
|
sourceKind: 'local',
|
|
source: `/tmp/${input.id}`,
|
|
capabilitiesGranted: ['prompt:inject'],
|
|
fsPath: `/tmp/${input.id}`,
|
|
installedAt: 1_700_000_000_000,
|
|
updatedAt: 1_700_000_000_000,
|
|
manifest: {
|
|
name: input.id,
|
|
title: input.title,
|
|
version: '1.0.0',
|
|
description: input.description,
|
|
tags: input.tags ?? [],
|
|
od: {
|
|
kind: 'scenario',
|
|
taskKind: input.taskKind,
|
|
mode: input.mode,
|
|
...(input.featured ? { featured: true } : {}),
|
|
...(input.query
|
|
? {
|
|
useCase: {
|
|
query: { en: input.query },
|
|
},
|
|
}
|
|
: {}),
|
|
...(input.previewEntry
|
|
? {
|
|
preview: {
|
|
type: 'html',
|
|
entry: input.previewEntry,
|
|
},
|
|
}
|
|
: {}),
|
|
},
|
|
},
|
|
};
|
|
}
|
|
|
|
function escapeHtml(value: string): string {
|
|
return value
|
|
.replaceAll('&', '&')
|
|
.replaceAll('<', '<')
|
|
.replaceAll('>', '>')
|
|
.replaceAll('"', '"');
|
|
}
|