mirror of
https://github.com/nexu-io/open-design.git
synced 2026-10-27 09:51:12 +01:00
377 lines
16 KiB
TypeScript
377 lines
16 KiB
TypeScript
import { expect, test } from '@/playwright/suite';
|
|
import { ensureRailOpen, openNewProjectModal } from '@/playwright/rail';
|
|
import { T } from '@/timeouts';
|
|
import {
|
|
captureVisual,
|
|
captureVisualTarget,
|
|
configureVisualPage,
|
|
gotoVisualHome,
|
|
mockSignedInVelaAccount,
|
|
scrollVisualLocatorIntoStableView,
|
|
VISUAL_AMR_AGENT,
|
|
VISUAL_CLI_AGENTS,
|
|
waitForVisualFonts,
|
|
waitForVisualProjects,
|
|
} from '@/playwright/visual';
|
|
|
|
test('[P2] captures the onboarding cloud sign-in surface', async ({ page }) => {
|
|
test.setTimeout(T.xlong);
|
|
|
|
await configureVisualPage(page, {
|
|
projects: [],
|
|
agents: [VISUAL_AMR_AGENT, ...VISUAL_CLI_AGENTS],
|
|
velaLoggedIn: false,
|
|
config: {
|
|
onboardingCompleted: false,
|
|
},
|
|
});
|
|
|
|
await page.goto('/onboarding', { waitUntil: 'domcontentloaded' });
|
|
await page.getByText('Loading OpenDesign…').waitFor({ state: 'hidden', timeout: T.long });
|
|
// Cloud stays primary while identity-independent Local Agent and BYOK setup
|
|
// remain available directly from the signed-out landing.
|
|
await expect(
|
|
page.getByRole('heading', { name: /Welcome to OpenDesign|欢迎使用 OpenDesign/i }),
|
|
).toBeVisible({ timeout: T.medium });
|
|
await expect(
|
|
page.getByRole('button', { name: /Sign in \/ Sign up|登录 \/ 注册/i }),
|
|
).toBeVisible();
|
|
await expect(
|
|
page.getByRole('button', { name: /Local AI|本地 AI/i }),
|
|
).toBeVisible();
|
|
await expect(
|
|
page.getByRole('button', { name: /API Key|API 密钥/i }),
|
|
).toBeVisible();
|
|
await waitForVisualFonts(page);
|
|
|
|
await captureVisual(page, 'visual-onboarding-cloud');
|
|
});
|
|
|
|
// The step past sign-in had no baseline at all, so the one surface whose whole
|
|
// job is to line up a two-column grid of detected CLIs was invisible to the
|
|
// visual suite. `visual-avatar-local-agent-list` covers the avatar menu's agent
|
|
// list — a different component — and stayed 0px through an alignment change to
|
|
// this one.
|
|
test('[P2] captures the onboarding Local Agent CLI list surface', async ({ page }) => {
|
|
test.setTimeout(T.xlong);
|
|
|
|
await configureVisualPage(page, {
|
|
projects: [],
|
|
agents: [VISUAL_AMR_AGENT, ...VISUAL_CLI_AGENTS],
|
|
config: {
|
|
onboardingCompleted: false,
|
|
},
|
|
});
|
|
await mockSignedInVelaAccount(page);
|
|
|
|
await page.goto('/onboarding', { waitUntil: 'domcontentloaded' });
|
|
await page.getByText('Loading OpenDesign…').waitFor({ state: 'hidden', timeout: T.long });
|
|
|
|
await page
|
|
.getByRole('button', { name: /Continue \(signed in\)|继续(已登录)/i })
|
|
.click();
|
|
await expect(
|
|
page.getByRole('heading', { name: /Choose your model source|选择模型来源/i }),
|
|
).toBeVisible({ timeout: T.medium });
|
|
await page.getByRole('radio', { name: /Local Agent|本地 Agent/i }).click();
|
|
await page.getByRole('button', { name: /^(Continue|继续)$/ }).click();
|
|
|
|
const panel = page.locator('.onboarding-view__setup-panel');
|
|
await expect(panel).toBeVisible({ timeout: T.medium });
|
|
const chips = page.locator('.onboarding-view__agent-chip');
|
|
// More than one chip is the point: a single card cannot show whether the
|
|
// column shares an alignment line.
|
|
await expect(chips.first()).toBeVisible();
|
|
expect(await chips.count()).toBeGreaterThan(1);
|
|
// The panel validates the selected agent on its own, so its status line is
|
|
// part of the surface being archived. Let that settle first, or the capture
|
|
// races the transient "testing" copy.
|
|
await expect(panel.locator('.onboarding-view__test-status.is-success')).toBeVisible({
|
|
timeout: T.medium,
|
|
});
|
|
await waitForVisualFonts(page);
|
|
|
|
await captureVisual(page, 'visual-onboarding-local-agent');
|
|
await captureVisualTarget(page, 'visual-onboarding-local-agent-panel', panel);
|
|
});
|
|
|
|
test('[P2] captures the visual home harness', async ({ page }) => {
|
|
await configureVisualPage(page, { projects: [] });
|
|
await gotoVisualHome(page);
|
|
|
|
await expect(page.getByTestId('home-hero')).toBeVisible();
|
|
await expect(page.getByTestId('home-hero-input')).toBeVisible();
|
|
await waitForVisualProjects(page, []);
|
|
|
|
await captureVisual(page, 'visual-home');
|
|
});
|
|
|
|
test('[P2] captures the unpaid DeepSeek campaign at narrow and short viewport boundaries', async ({ page }) => {
|
|
test.setTimeout(T.xlong);
|
|
|
|
await page.clock.setFixedTime('2026-08-21T00:00:00+08:00');
|
|
await page.setViewportSize({ width: 600, height: 720 });
|
|
await configureVisualPage(page, { projects: [] });
|
|
await mockSignedInVelaAccount(page, { plan: 'free' });
|
|
await gotoVisualHome(page);
|
|
// Functional specs seed campaign dismissals globally so marketing surfaces
|
|
// cannot interrupt unrelated flows. This visual contract deliberately opts
|
|
// back into the DeepSeek modal after establishing same-origin storage.
|
|
await page.evaluate(() => {
|
|
window.localStorage.removeItem('open-design:campaign-seen:deepseek-v4-dual-unlimited-2026');
|
|
});
|
|
await ensureRailOpen(page);
|
|
await page.getByTestId('entry-nav-community').evaluate((element: HTMLButtonElement) => {
|
|
element.click();
|
|
});
|
|
await expect(page.getByTestId('entry-view-home')).toHaveAttribute('data-active', 'false');
|
|
await page.getByTestId('entry-nav-home').evaluate((element: HTMLButtonElement) => {
|
|
element.click();
|
|
});
|
|
|
|
const dialog = page.getByTestId('deepseek-v4-flash-campaign-dialog');
|
|
const close = page.getByRole('button', { name: 'Close' });
|
|
const cta = page.getByRole('button', { name: 'Upgrade and use' });
|
|
await expect(dialog).toBeVisible();
|
|
await expect(close).toBeVisible();
|
|
await expect(cta).toBeVisible();
|
|
await expectInsideViewport(page, dialog);
|
|
await expectInsideViewport(page, close);
|
|
await expectInsideViewport(page, cta);
|
|
await captureVisual(page, 'visual-deepseek-unpaid-campaign-600');
|
|
|
|
await page.setViewportSize({ width: 760, height: 400 });
|
|
await expect(close).toBeVisible();
|
|
await expectInsideViewport(page, dialog);
|
|
await expectInsideViewport(page, close);
|
|
await expect.poll(async () => dialog.evaluate((element) => (
|
|
element.scrollHeight > element.clientHeight
|
|
))).toBe(true);
|
|
await captureVisual(page, 'visual-deepseek-unpaid-campaign-short-height');
|
|
await cta.scrollIntoViewIfNeeded();
|
|
await expect(cta).toBeVisible();
|
|
await expectInsideViewport(page, cta);
|
|
});
|
|
|
|
test('[P2] captures the home plugin catalog surface', async ({ page }) => {
|
|
test.setTimeout(90_000);
|
|
|
|
await configureVisualPage(page);
|
|
const plugins = await openVisualPluginsCatalog(page);
|
|
|
|
const catalog = plugins.locator('.plugin-marketplace__catalog');
|
|
await expect(catalog).toBeVisible();
|
|
await scrollVisualLocatorIntoStableView(page, catalog);
|
|
await expect(pluginMarketplaceCard(plugins, 'Prototype Starter')).toBeVisible();
|
|
await expect(pluginMarketplaceCard(plugins, 'Deck Writer')).toBeVisible();
|
|
await expect(plugins.locator('.plugin-marketplace__search input')).toBeVisible();
|
|
|
|
await captureVisual(page, 'visual-home-catalog');
|
|
});
|
|
|
|
test('[P2] captures the home plugin filtered surface', async ({ page }) => {
|
|
await configureVisualPage(page);
|
|
const plugins = await openVisualPluginsCatalog(page);
|
|
|
|
await plugins.locator('.plugin-marketplace__search input').fill('Deck');
|
|
await expect(pluginMarketplaceCard(plugins, 'Deck Writer')).toBeVisible();
|
|
await expect(pluginMarketplaceCard(plugins, 'Prototype Starter')).toHaveCount(0);
|
|
|
|
await captureVisual(page, 'visual-home-plugin-filter');
|
|
});
|
|
|
|
test('[P2] captures the home plugin detail surface', async ({ page }) => {
|
|
await configureVisualPage(page);
|
|
const plugins = await openVisualPluginsCatalog(page);
|
|
|
|
const card = pluginMarketplaceCard(plugins, 'Prototype Starter');
|
|
await expect(card).toBeVisible();
|
|
await card.locator('.plugin-marketplace__more').click();
|
|
await expect(card.locator('.plugin-marketplace__menu[role="menu"]')).toBeVisible();
|
|
|
|
await captureVisual(page, 'visual-plugin-details');
|
|
});
|
|
|
|
test('[P2] captures the plugin detail share menu surface', async ({ page }) => {
|
|
await configureVisualPage(page);
|
|
const plugins = await openVisualPluginsCatalog(page);
|
|
|
|
const card = pluginMarketplaceCard(plugins, 'Deck Writer');
|
|
await expect(card).toBeVisible();
|
|
const trigger = card.locator('.plugin-marketplace__more');
|
|
await trigger.click();
|
|
const popover = card.locator('.plugin-marketplace__menu[role="menu"]');
|
|
await expect(popover).toBeVisible();
|
|
|
|
await captureVisual(page, 'visual-plugin-share-menu');
|
|
await captureVisualTarget(page, 'visual-plugin-share-menu-popover', [trigger, popover]);
|
|
});
|
|
|
|
test('[P2] plugin detail owns vertical scrolling inside the fixed workspace shell', async ({ page }) => {
|
|
await configureVisualPage(page);
|
|
const plugins = await openVisualPluginsCatalog(page);
|
|
// Navigate with the standard visual viewport; shrink only the detail page so
|
|
// the assertion owns the detail scroller rather than the responsive nav.
|
|
await page.setViewportSize({ width: 960, height: 600 });
|
|
|
|
const card = plugins.getByTestId('plugins-card-visual-prototype-starter');
|
|
await expect(card).toBeVisible();
|
|
await card.locator('.plugin-marketplace__row-main').click();
|
|
await expect(page).toHaveURL(/\/marketplace\/visual-prototype-starter$/);
|
|
|
|
const detail = page.locator('.plugin-suite-detail');
|
|
await expect(detail).toBeVisible();
|
|
await expect(detail).toHaveCSS('overflow-y', 'auto');
|
|
const before = await detail.evaluate((element) => ({
|
|
clientHeight: element.clientHeight,
|
|
scrollHeight: element.scrollHeight,
|
|
scrollTop: element.scrollTop,
|
|
}));
|
|
expect(before.scrollHeight).toBeGreaterThan(before.clientHeight);
|
|
expect(before.scrollTop).toBe(0);
|
|
|
|
await detail.evaluate((element) => element.scrollTo({ top: element.scrollHeight }));
|
|
await expect.poll(() => detail.evaluate((element) => element.scrollTop)).toBeGreaterThan(0);
|
|
});
|
|
|
|
test('[P2] captures the home context picker surface', async ({ page }) => {
|
|
await configureVisualPage(page);
|
|
await gotoVisualHome(page);
|
|
|
|
await page.getByTestId('home-hero-input').fill('@visual');
|
|
const input = page.getByTestId('home-hero-input');
|
|
const picker = page.getByTestId('home-hero-plugin-picker');
|
|
await expect(picker).toBeVisible();
|
|
await expect(page.getByRole('option', { name: /Prototype Starter/i })).toBeVisible();
|
|
|
|
await captureVisual(page, 'visual-home-context-picker');
|
|
await captureVisualTarget(page, 'visual-home-context-picker-popover', [input, picker]);
|
|
});
|
|
|
|
test('[P2] captures the home staged attachment surface', async ({ page }) => {
|
|
await configureVisualPage(page);
|
|
await gotoVisualHome(page);
|
|
|
|
await page.getByTestId('home-hero-file-input').setInputFiles({
|
|
name: 'visual-brief.txt',
|
|
mimeType: 'text/plain',
|
|
buffer: Buffer.from('Visual regression fixture for staged home attachments.\n', 'utf8'),
|
|
});
|
|
await expect(page.getByTestId('home-hero-staged-files')).toContainText('visual-brief.txt');
|
|
|
|
await captureVisual(page, 'visual-home-staged-attachment');
|
|
});
|
|
|
|
test('[P2] captures the home plugin use staged surface', async ({ page }) => {
|
|
await configureVisualPage(page);
|
|
// #5517 removed Home's own plugin grid: `PluginsHomeSection` (and with it
|
|
// `plugins-home-pill-category-*` / `plugins-home__card`) now lives only in
|
|
// the unrendered legacy `PluginsView`; `EntryShell` mounts
|
|
// `ExtensionsMarketplace` on /plugins instead. The journey this capture
|
|
// exists for is unchanged — narrow the catalog, open the plugin's details,
|
|
// Use it — and Use still hands the plugin to Home's hero, which is the
|
|
// state being captured.
|
|
const plugins = await openVisualPluginsCatalog(page);
|
|
|
|
// Category chips are derived from the same `extractCategories` taxonomy the
|
|
// old Home pills used, so the fixture still lands under Prototype; the chips
|
|
// carry no per-slug testid, only the taxonomy's `Prototype` label.
|
|
await plugins
|
|
.getByTestId('plugins-category-tags')
|
|
.getByRole('button', { name: 'Prototype', exact: true })
|
|
.click();
|
|
// The filter has to really bite: Deck Writer is the deck-mode fixture.
|
|
await expect(pluginMarketplaceCard(plugins, 'Deck Writer')).toHaveCount(0);
|
|
|
|
const card = plugins.getByTestId('plugins-card-visual-prototype-starter');
|
|
await expect(card).toBeVisible();
|
|
// The row's own "Try it" button stops propagation, so target the row body —
|
|
// clicking the card anywhere else is what opens the plugin's details.
|
|
await card.locator('.plugin-marketplace__row-main').click();
|
|
// #5517 turned plugin details into a full-page route: `openCardDetail` calls
|
|
// navigate({ kind: 'marketplace-detail' }) for plugin records, so the details
|
|
// surface is `PluginDetailView` at /marketplace/<id> — not a role="dialog"
|
|
// overlay — and its Use control is the single `plugin-detail-use` button
|
|
// rather than a per-slug `plugin-details-use-<id>` menu item.
|
|
// Assert on the Use control rather than the `plugin-detail` shell: the shell
|
|
// also renders for the loading and load-failed states, so it would go green
|
|
// on a detail that never resolved.
|
|
await expect(page).toHaveURL(/\/marketplace\/visual-prototype-starter$/);
|
|
const usePlugin = page.getByTestId('plugin-detail-use');
|
|
await expect(usePlugin).toBeVisible();
|
|
await usePlugin.click();
|
|
// The lead chip cuts the title to eight code points (#7635).
|
|
await expect(page.getByTestId('home-hero-active-plugin')).toContainText('Prototyp…');
|
|
await expect(page.getByTestId('home-hero-input')).toBeVisible();
|
|
|
|
await captureVisual(page, 'visual-home-plugin-use-staged');
|
|
});
|
|
|
|
test('[P2] captures the home plugin use with query surface', async ({ page }) => {
|
|
await configureVisualPage(page);
|
|
const plugins = await openVisualPluginsCatalog(page);
|
|
|
|
await plugins.locator('.plugin-marketplace__search input').fill('Deck');
|
|
const card = pluginMarketplaceCard(plugins, 'Deck Writer');
|
|
await expect(card).toBeVisible();
|
|
await card.getByRole('button', { name: 'Try it' }).click();
|
|
await expect(page.getByTestId('home-hero-active-plugin')).toContainText('Deck Wri…');
|
|
await expect(page.getByTestId('home-hero-input')).toBeVisible();
|
|
|
|
await captureVisual(page, 'visual-home-plugin-use-with-query');
|
|
});
|
|
|
|
test('[P2] captures the new project modal surface', async ({ page }) => {
|
|
test.setTimeout(T.xlong);
|
|
|
|
await configureVisualPage(page);
|
|
await gotoVisualHome(page);
|
|
|
|
await openNewProjectModal(page);
|
|
await expect(page.getByTestId('new-project-name')).toBeVisible();
|
|
|
|
await captureVisual(page, 'visual-new-project-modal');
|
|
});
|
|
|
|
async function openVisualPluginsCatalog(page: import('@playwright/test').Page) {
|
|
await gotoVisualHome(page);
|
|
await ensureRailOpen(page);
|
|
await page.getByTestId('entry-nav-plugins').click();
|
|
await expect(page).toHaveURL(/\/plugins$/);
|
|
const plugins = page.getByTestId('entry-view-plugins');
|
|
// The view renders `entry.navPlugins`: #5517 briefly called this surface
|
|
// 扩展/Extensions, then reverted to 插件/Plugins to match the @-mention picker.
|
|
await expect(plugins.getByRole('heading', { name: 'Plugins', exact: true })).toBeVisible();
|
|
// The marketplace opens on the 官方 scope, which is fed by `/api/marketplaces`
|
|
// — empty in this harness. The visual fixture plugins are user-installed, so
|
|
// switch to 个人; it is also the only scope whose cards carry the per-card
|
|
// overflow menu (share / unshare / uninstall) the menu captures need.
|
|
await plugins.getByTestId('plugins-tab-installed').click();
|
|
return plugins;
|
|
}
|
|
|
|
function pluginMarketplaceCard(root: import('@playwright/test').Locator, title: string) {
|
|
return root.locator('article.plugin-marketplace__item').filter({ hasText: title }).first();
|
|
}
|
|
|
|
async function expectInsideViewport(
|
|
page: import('@playwright/test').Page,
|
|
locator: import('@playwright/test').Locator,
|
|
): Promise<void> {
|
|
const viewport = page.viewportSize();
|
|
expect(viewport).not.toBeNull();
|
|
await expect.poll(async () => {
|
|
const box = await locator.boundingBox();
|
|
if (!box || !viewport) return null;
|
|
return {
|
|
left: Math.max(0, -box.x),
|
|
top: Math.max(0, -box.y),
|
|
right: Math.max(0, box.x + box.width - (viewport.width + 1)),
|
|
bottom: Math.max(0, box.y + box.height - (viewport.height + 1)),
|
|
};
|
|
}, {
|
|
message: 'expected locator bounds to settle inside the viewport',
|
|
timeout: T.short,
|
|
}).toEqual({ left: 0, top: 0, right: 0, bottom: 0 });
|
|
}
|