Files
open-design/e2e/ui/app-manual-edit.test.ts

1561 lines
68 KiB
TypeScript

import { expect, test } from '@/playwright/suite';
import { ACTIVE_ARTIFACT_PREVIEW_SELECTOR } from '@/playwright/artifact-preview';
import { expectStableCount } from '@/playwright/assertions';
import { applyStandardMocks, routeAgents, routeSuccessfulRuns } from '@/playwright/mock-factory';
import {
clickDeckNextSlide,
clickPreviewToolbarAction,
openAllProjectFiles,
openPreviewToolbarMoreMenu,
} from '@/playwright/workspace';
import type { Page } from '@playwright/test';
import { pathToFileURL } from 'node:url';
import { T } from '@/timeouts';
const STORAGE_KEY = 'open-design:config';
test.describe.configure({ timeout: T.xlong });
function artifactPreview(page: Page) {
return page.locator(ACTIVE_ARTIFACT_PREVIEW_SELECTOR).first();
}
function artifactPreviewFrame(page: Page) {
return page.frameLocator(ACTIVE_ARTIFACT_PREVIEW_SELECTOR);
}
test.beforeEach(async ({ page }) => {
await applyStandardMocks(page);
});
test.use({ screenshot: 'off', video: 'off', trace: 'off' });
test('B2 confirmed PDF save survives focus history refresh in the running product', async ({ page }, testInfo) => {
await routeMockAgents(page);
const projectId = await createEmptyProject(page, 'B2 export guide');
await seedHtmlArtifact(page, projectId, 'guide.html', '<!doctype html><html><head><title>B2 export</title></head><body><h1>B2 export fixture</h1></body></html>');
await page.route('**/api/integrations/vela/status*', route => route.fulfill({ json: {
loggedIn: true, profile: 'test', configPath: '', user: { id: 'b2-browser-account', email: 'fixture@example.invalid' },
} }));
let refreshing = false;
let releaseHistory!: () => void;
const historyReady = new Promise<void>(resolve => { releaseHistory = resolve; });
let refreshRead = false;
await page.route(`**/api/projects/${projectId}/share-state`, async route => {
if (refreshing) { refreshRead = true; await historyReady; }
await route.fulfill({ json: { projectId, bindingExists: false, hasEverShared: false, publications: [] } });
});
// Controlled native-save acknowledgement at the production HTTP boundary.
// This proves UI consumption, not an actual OS save or cloud login.
await page.route(`**/api/projects/${projectId}/export/pdf`, async route => {
refreshing = true;
await page.evaluate(() => window.dispatchEvent(new Event('focus')));
await expect.poll(() => refreshRead).toBe(true);
await route.fulfill({ json: { ok: true } });
});
await page.goto(`/projects/${projectId}/files/guide.html`);
await openDesignFile(page, 'guide.html');
await expect(artifactPreview(page)).toBeVisible();
const guide = page.getByRole('status', { name: 'Share the link, invite feedback', exact: true });
await expect(guide).toHaveCount(0);
await page.getByRole('button', { name: 'Export', exact: true }).click();
const saved = page.waitForResponse(response => response.url().endsWith(`/api/projects/${projectId}/export/pdf`) && response.request().method() === 'POST');
await page.getByRole('menuitem', { name: /Export as PDF/i }).click();
expect((await saved).ok()).toBe(true);
await expect(guide).toHaveCount(0);
releaseHistory();
const capture = testInfo.outputPath('b2-guide.png');
try {
await expect(guide).toBeVisible();
await guide.hover();
await expect(guide).not.toContainText('Awaiting');
} finally {
await page.screenshot({ path: capture });
await testInfo.attach('B2 production entry, controlled native save ack; OS save UNKNOWN', { path: capture, contentType: 'image/png' });
}
await guide.getByRole('button', { name: 'Try sharing', exact: true }).click();
await expect(guide).toHaveCount(0);
await expect(page.getByRole('button', { name: 'Share', exact: true })).toHaveAttribute('aria-expanded', 'true');
await expect(page.getByRole('menu').getByRole('heading', { name: 'Share', exact: true })).toBeVisible();
});
test.describe('K8 scoped comment sync banner', () => {
test('real preview entry reads sync state and clears the banner after login refresh', async ({ page }) => {
await routeMockAgents(page);
const projectId = await createEmptyProject(page, 'K8 sync banner');
await seedHtmlArtifact(page, projectId, 'sync.html', manualEditHtml());
// First prove production registration on the actual tools-dev daemon.
// A local project has no verified cloud scope, so the honest result is null.
const actual = await page.request.get(`/api/projects/${projectId}/comment-sync-state`);
expect(actual.status()).toBe(200);
expect(await actual.json()).toBeNull();
let sessionMissing = true;
let reads = 0;
// Controlled upstream states prove the running UI consumption path, not
// successful cloud authentication or real scoped outbox delivery.
await page.route(`**/api/projects/${projectId}/comment-sync-state`, async route => {
expect(route.request().method()).toBe('GET');
reads += 1;
await route.fulfill({ json: { pending: sessionMissing ? 1 : 0, lastError: 'COMMENT_SYNC_DELIVERY_FAILED', sessionMissing, shareStopped: null } });
});
await page.goto(`/projects/${projectId}/files/sync.html`);
await openDesignFile(page, 'sync.html');
await expect(artifactPreview(page)).toBeVisible();
await clickPreviewToolbarAction(page, 'comment-panel-toggle', /^Comments \(\d+\)$/);
const panel = page.getByTestId('comment-side-panel');
await expect(panel).toBeVisible();
const banner = panel.getByRole('status');
await expect(banner).toContainText('Comment sync is paused');
await expect(banner.getByRole('button', { name: 'Sign in', exact: true })).toBeEnabled();
await expect(page.getByTestId('board-mode-toggle')).toBeEnabled();
const beforeLogin = reads;
sessionMissing = false;
await page.evaluate(() => window.dispatchEvent(new CustomEvent('od:amr-login-status-change', { detail: { reason: 'login' } })));
await expect.poll(() => reads).toBeGreaterThan(beforeLogin);
await expect(banner).toHaveCount(0);
await expect(panel).toBeVisible();
await expect(page.getByTestId('board-mode-toggle')).toBeEnabled();
});
});
test('[P0] manual edit inspector previews and persists page and selected element styles', async ({ page }) => {
await routeMockAgents(page);
const projectId = await createEmptyProject(page, 'Manual edit smoke');
await seedHtmlArtifact(page, projectId, 'manual-edit.html', manualEditHtml());
await page.goto(`/projects/${projectId}/files/manual-edit.html`);
await openDesignFile(page, 'manual-edit.html');
await expect(artifactPreview(page)).toBeVisible();
const frame = artifactPreviewFrame(page);
await expect(frame.getByRole('heading', { name: 'Original Hero' })).toBeVisible();
await expect.poll(() => previewCss(page, '[data-od-id="responsive-pair"]', 'flexDirection'))
.toMatch(/^(row|column)$/);
const initialFlexDirection = await previewCss(
page,
'[data-od-id="responsive-pair"]',
'flexDirection',
);
await clickPreviewToolbarAction(page, 'manual-edit-mode-toggle', /^Edit$/);
await expect(frame.locator('html[data-od-edit-mode]')).toHaveCount(1);
await expect.poll(() => previewCss(page, '[data-od-id="responsive-pair"]', 'flexDirection'))
.toBe(initialFlexDirection);
await frame.locator('body').evaluate(() => {
window.parent.postMessage({ type: 'od-edit-background' }, '*');
});
await expect(page.locator('.manual-edit-modal')).toContainText('PAGE');
await expect(page.locator('.manual-edit-tabs')).toHaveCount(0);
await expect(page.locator('.manual-edit-layer-row')).toHaveCount(0);
await inspectorRow(page, 'Background').locator('input').fill('#eef2ff');
await inspectorRow(page, 'Font').locator('select').selectOption('Georgia, serif');
await inspectorRow(page, 'Base size').locator('input').fill('18');
await expect(inspectorRow(page, 'Background').locator('input:not([type="color"])')).toHaveValue('#eef2ff');
await expect(inspectorRow(page, 'Font').locator('select')).toHaveValue('Georgia, serif');
await expect(inspectorRow(page, 'Base size').locator('input')).toHaveValue('18');
await selectPreviewElementThroughBridge(page, frame, '[data-od-id="hero-title"]', 'Parameters');
const selectedTitleMarker = frame.locator('[data-od-id="hero-title"][data-od-edit-selected="true"]');
await expect(selectedTitleMarker).toHaveCount(1);
const parameters = inspectorSection(page, 'Parameters');
const fontSizeInput = parameters.locator('.cc-row').filter({ hasText: 'Font size' }).locator('input');
await fontSizeInput.click();
await expect(selectedTitleMarker).toHaveCount(1);
await expect(fontSizeInput).not.toHaveValue('');
await expect(fontSizeInput).not.toHaveValue(/px/i);
await expect(parameters.locator('.cc-row').filter({ hasText: 'Text color' }).locator('input:not([type="color"])')).toHaveValue(/^#[0-9a-f]{6}$/);
const lineInput = parameters.locator('.cc-row').filter({ hasText: 'Line height' }).locator('input');
await lineInput.click();
await lineInput.blur();
await expect(page.locator('.manual-edit-error')).toHaveCount(0);
await frame.locator('body').evaluate(() => {
window.parent.postMessage({ type: 'od-edit-targets', targets: [] }, '*');
});
await expect(page.locator('.manual-edit-modal')).toContainText('Parameters');
await expect(page.locator('.manual-edit-modal')).not.toContainText('PAGE');
await frame.locator('body').evaluate(() => {
(window as Window & typeof globalThis & { __manualEditSmokeMarker?: string }).__manualEditSmokeMarker = 'stable-frame';
});
await fontSizeInput.fill('48');
await parameters.locator('.cc-row').filter({ hasText: 'Text color' }).locator('input:not([type="color"])').fill('#ef4444');
await expect(fontSizeInput).toHaveValue('48');
const title = frame.getByRole('heading', { name: 'Original Hero' });
await expect.poll(async () => title.evaluate((el) => getComputedStyle(el).fontSize)).toBe('48px');
await expect(title).toHaveCSS('color', 'rgb(239, 68, 68)');
await inspectSaveButton(page).click({ force: true });
await expectFileSource(page, projectId, 'manual-edit.html', [
'font-size: 48px',
'color:',
]);
await expectFileSourceExcludes(page, projectId, 'manual-edit.html', ['data-od-edit-selected']);
await expect(page.locator('.manual-edit-error')).toHaveCount(0);
await clickPreviewToolbarAction(page, 'manual-edit-mode-toggle', /^Edit$/);
await expect(frame.getByRole('heading', { name: 'Original Hero' })).toBeVisible();
const viewMode = page.getByRole('tablist', { name: 'View mode' });
await expect(viewMode).toBeVisible();
await expect(viewMode.getByRole('tab', { name: 'Preview', exact: true })).toHaveAttribute('aria-selected', 'true');
await expect(viewMode.getByRole('tab', { name: 'Code', exact: true })).toBeVisible();
await expect(artifactPreview(page)).toBeVisible();
await clickPreviewToolbarAction(page, 'board-mode-toggle', /^Comment$/);
await expect(page.getByTestId('board-mode-toggle')).toHaveAttribute('aria-pressed', 'true');
await expect(page.getByRole('button', { name: /^Share$/ })).toBeVisible();
const actionMenu = await openShareExportMenu(page);
await expect(actionMenu.getByRole('menuitem', { name: /Export as PDF/i })).toBeVisible();
});
test('[P0] manual edit mode preserves the current page in a multi-page mobile app', async ({ page }) => {
await routeMockAgents(page);
const projectId = await createEmptyProject(page, 'Multi-page mobile edit');
await seedHtmlArtifact(page, projectId, 'mobile-app.html', multiPageMobileHtml());
await page.goto(`/projects/${projectId}/files/mobile-app.html`);
await openDesignFile(page, 'mobile-app.html');
const preview = artifactPreviewFrame(page);
await expect(preview.getByTestId('mobile-page-home')).toBeVisible();
await page.getByRole('tab', { name: 'Code', exact: true }).click();
await expect(page.getByRole('tab', { name: 'Code', exact: true })).toHaveAttribute('aria-selected', 'true');
await page.getByRole('tab', { name: 'Preview', exact: true }).click();
await expect(page.getByRole('tab', { name: 'Preview', exact: true })).toHaveAttribute('aria-selected', 'true');
const prewarmedSrcDoc = page.frameLocator('iframe[data-testid="artifact-preview-frame-srcdoc"]');
await expect(prewarmedSrcDoc.getByTestId('mobile-page-home')).toBeAttached();
await waitForUrlPreviewRefreshToSettle(page);
await preview.getByRole('button', { name: 'Profile' }).click();
await expect(preview.getByTestId('mobile-page-profile')).toBeVisible();
await expect(preview.getByTestId('mobile-page-home')).toBeHidden();
await clickPreviewToolbarAction(page, 'manual-edit-mode-toggle', /^Edit$/);
await expect(page.getByTestId('manual-edit-mode-toggle')).toHaveAttribute('aria-pressed', 'true');
await expect(preview.getByTestId('mobile-page-profile')).toBeVisible();
await expect(preview.getByTestId('mobile-page-home')).toBeHidden();
await clickPreviewToolbarAction(page, 'manual-edit-mode-toggle', /^Edit$/);
await expect(page.getByTestId('manual-edit-mode-toggle')).toHaveAttribute('aria-pressed', 'false');
await expect(preview.locator('html[data-od-edit-mode]')).toHaveCount(0);
await expect(preview.getByTestId('mobile-page-profile')).toBeVisible();
await preview.getByRole('button', { name: 'Home' }).click();
await expect(preview.getByTestId('mobile-page-home')).toBeVisible();
await expect(preview.getByTestId('mobile-page-profile')).toBeHidden();
await clickPreviewToolbarAction(page, 'manual-edit-mode-toggle', /^Edit$/);
await expect(page.getByTestId('manual-edit-mode-toggle')).toHaveAttribute('aria-pressed', 'true');
await preview.locator('[data-od-id="mobile-page-home"]').evaluate((element) => {
element.dispatchEvent(new MouseEvent('click', {
bubbles: true,
cancelable: true,
view: window,
}));
});
const selectedHtml = page.locator('.manual-edit-modal label')
.filter({ hasText: 'Selected element HTML' })
.locator('textarea');
await expect(selectedHtml).toBeVisible();
const currentHtml = await selectedHtml.inputValue();
const editedHtml = currentHtml.replace(
'data-page="home"',
'data-page="home" data-edit-revision="fresh"',
);
expect(editedHtml).not.toBe(currentHtml);
await selectedHtml.fill(editedHtml);
await inspectSaveButton(page).click();
// Saving rebuilds the srcDoc transport. The consumed Profile snapshot must
// not replay over the newer Home navigation when that later load completes.
await expectFileSource(page, projectId, 'mobile-app.html', ['data-edit-revision="fresh"']);
await expect(preview.locator('[data-edit-revision="fresh"]')).toBeVisible();
await expect(preview.getByTestId('mobile-page-home')).toBeVisible();
await expect(preview.getByTestId('mobile-page-profile')).toBeHidden();
});
test('[P0] manual edit mode preserves a runtime-rendered mobile app page', async ({ page }) => {
await routeMockAgents(page);
const projectId = await createEmptyProject(page, 'Runtime-rendered mobile edit');
await seedHtmlArtifact(page, projectId, 'mobile-app.html', runtimeRenderedMobileHtml());
await page.goto(`/projects/${projectId}/files/mobile-app.html`);
await openDesignFile(page, 'mobile-app.html');
const preview = artifactPreviewFrame(page);
await expect(preview.getByTestId('mobile-page-today')).toBeVisible();
await page.getByRole('tab', { name: 'Code', exact: true }).click();
await page.getByRole('tab', { name: 'Preview', exact: true }).click();
await expect(page.frameLocator('iframe[data-testid="artifact-preview-frame-srcdoc"]')
.getByTestId('mobile-page-today')).toBeAttached();
await waitForUrlPreviewRefreshToSettle(page);
await preview.getByRole('button', { name: 'Profile' }).click();
await expect(preview.getByTestId('mobile-page-profile')).toBeVisible();
await expect(preview.getByRole('heading', { name: 'Profile page' })).toBeVisible();
await expect(preview.getByTestId('mobile-page-today')).toHaveCount(0);
await clickPreviewToolbarAction(page, 'manual-edit-mode-toggle', /^Edit$/);
await expect(page.getByTestId('manual-edit-mode-toggle')).toHaveAttribute('aria-pressed', 'true');
await expect(preview.getByTestId('mobile-page-profile')).toBeVisible();
await expect(preview.getByRole('heading', { name: 'Profile page' })).toBeVisible();
await expect(preview.getByRole('heading', { name: 'Today page' })).toHaveCount(0);
await expect(preview.getByTestId('mobile-page-today')).toHaveCount(0);
await preview.locator('[data-od-id="profile-screen"]').hover();
await expect(preview.locator('[data-od-edit-guides-layer]')).toHaveCount(1);
await expect(preview.locator('[data-od-edit-guides-layer] > *')).not.toHaveCount(0);
await selectPreviewElementThroughBridge(
page,
preview,
'[data-od-id="profile-screen"]',
'CONTENT',
);
await clickPreviewToolbarAction(page, 'manual-edit-mode-toggle', /^Edit$/);
await expect(page.getByTestId('manual-edit-mode-toggle')).toHaveAttribute('aria-pressed', 'false');
await expect(preview.locator('html[data-od-edit-mode]')).toHaveCount(0);
await preview.getByRole('button', { name: 'Today' }).click();
await expect(preview.getByTestId('mobile-page-today')).toBeVisible();
await expect(preview.getByTestId('mobile-page-profile')).toHaveCount(0);
await clickPreviewToolbarAction(page, 'manual-edit-mode-toggle', /^Edit$/);
await expect(page.getByTestId('manual-edit-mode-toggle')).toHaveAttribute('aria-pressed', 'true');
await expect(preview.getByTestId('mobile-page-today')).toBeVisible();
await expect(preview.getByTestId('mobile-page-profile')).toHaveCount(0);
await preview.locator('[data-od-id="today-screen"]').hover();
await expect(preview.locator('[data-od-edit-guides-layer] > *')).not.toHaveCount(0);
});
test('[P0] srcDoc page navigation keeps manual edit hover guides across files and re-entry', async ({ page }) => {
await routeMockAgents(page);
const projectId = await createEmptyProject(page, 'Cross-file mobile edit');
await seedHtmlArtifact(
page,
projectId,
'today.html',
linkedMobilePageHtml('Today page', 'today-screen', 'profile.html', 'Profile'),
);
await seedHtmlArtifact(
page,
projectId,
'profile.html',
linkedMobilePageHtml('Profile page', 'profile-screen', 'today.html', 'Today'),
);
await page.goto(`/projects/${projectId}/files/today.html`);
await openDesignFile(page, 'today.html');
const preview = artifactPreviewFrame(page);
await expect(preview.getByRole('heading', { name: 'Today page' })).toBeVisible();
await clickPreviewToolbarAction(page, 'manual-edit-mode-toggle', /^Edit$/);
await expect(page.getByTestId('manual-edit-mode-toggle')).toHaveAttribute('aria-pressed', 'true');
await preview.locator('[data-od-id="today-screen"]').hover();
await expect(preview.locator('[data-od-edit-guides-layer] > *')).not.toHaveCount(0);
await clickPreviewToolbarAction(page, 'manual-edit-mode-toggle', /^Edit$/);
await expect(page.getByTestId('manual-edit-mode-toggle')).toHaveAttribute('aria-pressed', 'false');
await expect(preview.locator('html[data-od-edit-mode]')).toHaveCount(0);
await preview.getByRole('link', { name: 'Profile' }).click();
await expect(tabBySuffix(page, 'profile.html')).toHaveAttribute('aria-selected', 'true');
await expect(preview.getByRole('heading', { name: 'Profile page' })).toBeVisible();
await clickPreviewToolbarAction(page, 'manual-edit-mode-toggle', /^Edit$/);
await expect(page.getByTestId('manual-edit-mode-toggle')).toHaveAttribute('aria-pressed', 'true');
await preview.locator('[data-od-id="profile-screen"]').hover();
await expect(preview.locator('[data-od-edit-guides-layer] > *')).not.toHaveCount(0);
await clickPreviewToolbarAction(page, 'manual-edit-mode-toggle', /^Edit$/);
await expect(preview.locator('html[data-od-edit-mode]')).toHaveCount(0);
await clickPreviewToolbarAction(page, 'manual-edit-mode-toggle', /^Edit$/);
await expect(preview.locator('html[data-od-edit-mode]')).toHaveCount(1);
await preview.locator('[data-od-id="profile-screen"]').hover();
await expect(preview.locator('[data-od-edit-guides-layer] > *')).not.toHaveCount(0);
});
async function waitForUrlPreviewRefreshToSettle(page: Page) {
const frame = page.locator(
'iframe[data-od-render-mode="url-load"][data-od-active="true"]',
);
let observedSrc: string | null = null;
let unchangedSince = Date.now();
await expect.poll(async () => {
const currentSrc = await frame.getAttribute('data-od-loaded-src');
if (!currentSrc || currentSrc === 'about:blank') return 0;
if (currentSrc !== observedSrc) {
observedSrc = currentSrc;
unchangedSince = Date.now();
}
return Date.now() - unchangedSince;
}, {
message: 'URL preview should stop reloading before the runtime-state interaction',
timeout: 5_000,
}).toBeGreaterThanOrEqual(400);
}
async function selectPreviewElementThroughBridge(
page: Page,
frame: ReturnType<Page['frameLocator']>,
selector: string,
section: string,
) {
await expect(frame.locator('html[data-od-edit-mode]')).toHaveCount(1);
// Entering manual-edit mode re-injects the edit bridge and re-emits its targets
// for a beat (`setTimeout(postTargets, 0)` in edit-mode/bridge.ts), and the
// preview iframe can still settle (srcDoc swap / target re-emit) at the moment we
// click. That occasionally swallows the first click, which then hangs on
// Playwright's post-click stability check until the 30s test timeout. Retry the
// click until the element is actually marked selected, with a short per-attempt
// timeout so a single dropped click rides through the settle window instead of
// failing the whole run.
await expect(async () => {
await frame.locator(selector).click({ timeout: 5_000 });
await expect(frame.locator(`${selector}[data-od-edit-selected="true"]`)).toHaveCount(1, { timeout: 2_000 });
}).toPass({ timeout: 30_000 });
await expect(page.locator('.manual-edit-modal')).toContainText(section);
}
test('[P0] @critical preview toolbar keeps share, download, comment, and zoom actions reachable', async ({ page }, testInfo) => {
await routeMockAgents(page);
const projectId = await createEmptyProject(page, 'Preview toolbar smoke');
const entryHtml = manualEditHtml()
.replace('/hero.png', 'assets/offline.svg')
.replace('</head>', '<link rel="stylesheet" href="styles/offline.css"></head>')
.replace(
'</body>',
'<img id="offline-image" src="assets/offline.svg">' +
'<script type="module" src="scripts/main.js"></script></body>',
);
await seedHtmlArtifact(page, projectId, 'toolbar-preview.html', entryHtml);
await seedProjectFile(
page,
projectId,
'styles/offline.css',
'body{--offline-export-proof:ready;background-image:url("../assets/offline.svg")}',
);
await seedProjectFile(
page,
projectId,
'scripts/main.js',
'import { markReady } from "./motion.js"; markReady();',
);
await seedProjectFile(
page,
projectId,
'scripts/motion.js',
'export const markReady = () => { document.body.dataset.offlineMotion = "ready"; };',
);
await seedProjectFile(
page,
projectId,
'assets/offline.svg',
'<svg xmlns="http://www.w3.org/2000/svg" width="2" height="2"><rect width="2" height="2" fill="red"/></svg>',
);
await page.goto(`/projects/${projectId}/files/toolbar-preview.html`);
await openDesignFile(page, 'toolbar-preview.html');
await expect(page.getByTestId('artifact-preview-frame')).toBeVisible();
const viewMode = page.getByRole('tablist', { name: 'View mode' });
await expect(viewMode).toBeVisible();
await expect(viewMode.getByRole('tab', { name: 'Preview', exact: true })).toHaveAttribute('aria-selected', 'true');
await expect(viewMode.getByRole('tab', { name: 'Code', exact: true })).toBeVisible();
// The three intents are top-level header controls again — Share, Export, and
// the hand-off split button sit side by side, with no tab strip to cross.
await expect(page.getByRole('button', { name: /^Export$/ })).toBeVisible();
await expect(page.getByTestId('handoff-trigger')).toBeVisible();
await page.getByRole('button', { name: /^Share$/ }).click();
const shareMenu = page.locator('.share-menu-popover[role="menu"]');
await expect(shareMenu).toBeVisible();
// Share opens straight onto the link/asset-shaped rows; file formats are
// Export's job now, so they must NOT be reachable from this panel.
await expect(shareMenu.getByRole('menuitem', { name: /Export as PDF/i })).toHaveCount(0);
// This local Personal fixture deliberately has neither a Team identity nor
// an authenticated public-publish capability. Keep this toolbar smoke about
// the stable action surface instead of requiring a workspace-specific card.
await expect(shareMenu.getByText(/Share project in workspace|Visibility in workspace/i)).toHaveCount(0);
await expect(shareMenu.getByText(/Publish this file/i)).toHaveCount(0);
await page.keyboard.press('Escape');
await expect(shareMenu).toHaveCount(0);
const downloadMenu = await openShareExportMenu(page);
await expect(downloadMenu).toBeVisible();
await expect(downloadMenu.getByRole('menuitem', { name: /Export as PDF/ })).toBeVisible();
await expect(downloadMenu.getByRole('menuitem', { name: /Download as \.zip/ })).toBeVisible();
const htmlExportResponse = page.waitForResponse((response) =>
response.url().endsWith(`/api/projects/${projectId}/export/html`),
);
const htmlDownload = page.waitForEvent('download');
await downloadMenu.getByRole('menuitem', { name: /Export as standalone HTML/ }).click();
const exportResponse = await htmlExportResponse;
expect(exportResponse.ok(), await exportResponse.text()).toBeTruthy();
const download = await htmlDownload;
expect(download.suggestedFilename()).toMatch(/toolbar-preview.*\.html$/i);
const offlinePath = testInfo.outputPath('offline-standalone.html');
await download.saveAs(offlinePath);
const offlinePage = await page.context().newPage();
const failedRequests: string[] = [];
const scriptErrors: string[] = [];
offlinePage.on('requestfailed', (request) => failedRequests.push(request.url()));
offlinePage.on('pageerror', (error) => scriptErrors.push(error.message));
offlinePage.on('console', (message) => {
if (message.type() === 'error') scriptErrors.push(message.text());
});
await offlinePage.goto(pathToFileURL(offlinePath).href, { waitUntil: 'load' });
try {
await expect.poll(() => offlinePage.locator('body').getAttribute('data-offline-motion')).toBe('ready');
} catch {
throw new Error(`offline module did not execute: ${scriptErrors.join(' | ') || 'no browser error reported'}`);
}
await expect.poll(() => offlinePage.locator('body').evaluate(
(body) => getComputedStyle(body).getPropertyValue('--offline-export-proof').trim(),
)).toBe('ready');
await expect.poll(() => offlinePage.locator('#offline-image').evaluate(
(image) => (image as HTMLImageElement).naturalWidth,
)).toBeGreaterThan(0);
expect(failedRequests).toEqual([]);
await offlinePage.close();
await expect(downloadMenu).toHaveCount(0);
await clickPreviewToolbarAction(page, 'board-mode-toggle', /^Comment$/);
await expect(page.getByTestId('board-mode-toggle')).toHaveAttribute('aria-pressed', 'true');
await clickPreviewToolbarAction(page, 'board-mode-toggle', /^Comment$/);
await expect(page.getByTestId('board-mode-toggle')).toHaveAttribute('aria-pressed', 'false');
const zoomButton = page.locator('.viewer-toolbar-zoom .zoom-trigger');
await expect(zoomButton).toHaveText(/^\d+%$/);
if (await zoomButton.isVisible()) {
await zoomButton.click();
const zoomMenu = page.locator('.zoom-menu-popover[role="menu"]');
await expect(zoomMenu).toBeVisible();
await zoomMenu.getByRole('menuitem', { name: '150%' }).click();
} else {
const overflowMenu = await openPreviewToolbarMoreMenu(page);
await overflowMenu.getByRole('menuitem', { name: '150%' }).click();
}
await expect(zoomButton).toHaveText('150%');
});
test('[P1] preview toolbar exports PDF and PPTX through the daemon contracts', async ({ page }) => {
test.setTimeout(60_000);
await routeMockAgents(page);
const projectId = await createProjectViaApi(page, 'Preview export contract');
const pdfRequests: Array<Record<string, unknown>> = [];
await page.route(`**/api/projects/${projectId}/export/pdf`, async (route) => {
pdfRequests.push(route.request().postDataJSON() as Record<string, unknown>);
await route.fulfill({
status: 200,
contentType: 'application/json',
body: '{"ok":true}',
});
});
await seedHtmlArtifact(page, projectId, 'export-page.html', manualEditHtml());
await page.goto(`/projects/${projectId}/files/export-page.html`);
await openDesignFile(page, 'export-page.html');
const pdfMenu = await openShareExportMenu(page);
await pdfMenu.getByRole('menuitem', { name: /Export as PDF/ }).click();
await expect
.poll(() => pdfRequests.length, { timeout: 10_000 })
.toBe(1);
expect(pdfRequests[0]).toMatchObject({
deck: false,
fileName: 'export-page.html',
title: 'export-page',
});
const pptxRequests: Array<Record<string, unknown>> = [];
await page.route(`**/api/projects/${projectId}/export/pptx`, async (route) => {
pptxRequests.push(route.request().postDataJSON() as Record<string, unknown>);
await route.fulfill({
status: 200,
headers: {
'content-type': 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
'content-disposition': 'attachment; filename="contract-deck.pptx"',
},
body: 'PK\u0003\u0004contract-pptx',
});
});
await seedDeckArtifact(page, projectId, 'contract-deck.html', 'Contract Deck', ['Intro', 'Details']);
await page.goto(`/projects/${projectId}/files/contract-deck.html`);
await openDesignFile(page, 'contract-deck.html');
await expect(artifactPreviewFrame(page).getByRole('heading', { name: 'Intro' })).toBeVisible();
const pptxMenu = await openShareExportMenu(page);
await pptxMenu.getByRole('menuitem', { name: /Export as PPTX/ }).click();
const dialog = page.getByRole('dialog', { name: /Export as PPTX/ });
await expect(dialog).toBeVisible();
await expect(dialog.getByRole('radio', { name: /^Export as PPTX \(editable\)/i })).toBeChecked();
const pptxDownload = page.waitForEvent('download');
await dialog.getByRole('button', { name: /Export/i }).click();
const download = await pptxDownload;
expect(download.suggestedFilename()).toBe('contract-deck.pptx');
await expect
.poll(() => pptxRequests.length, { timeout: 10_000 })
.toBe(1);
expect(pptxRequests[0]).toMatchObject({
deck: true,
editable: true,
fileName: 'contract-deck.html',
title: 'contract-deck',
});
});
test('[P1] powered WebGL HTML artifacts open through the isolated preview route', async ({ page }) => {
test.setTimeout(60_000);
await routeMockAgents(page);
const projectId = await createEmptyProject(page, 'Powered WebGL preview smoke');
await seedHtmlArtifact(page, projectId, 'powered-webgl.html', poweredWebglHtml());
await page.goto(`/projects/${projectId}/files/powered-webgl.html`);
await openDesignFile(page, 'powered-webgl.html');
const preview = artifactPreview(page);
await expect(preview).toBeVisible();
await expect(preview).toHaveAttribute('data-od-powered', 'true');
await expect(preview).toHaveAttribute('data-od-render-mode', 'url-load');
await expect(preview).toHaveAttribute('src', new RegExp(`/api/projects/${projectId}/powered/powered-webgl\\.html`));
const frame = artifactPreviewFrame(page);
await expect(frame.getByRole('heading', { name: 'Powered WebGL Smoke' })).toBeVisible();
await expect(frame.locator('#scene')).toBeVisible();
await expect(frame.getByTestId('powered-status')).toContainText(/isolated|not-isolated/);
});
test('[P1] HTML preview toolbar exposes comments, mark, and edit workflows', async ({ page }) => {
test.setTimeout(60_000);
await page.addInitScript(() => {
class TestClipboardItem {
constructor(public readonly items: Record<string, Blob | Promise<Blob>>) {}
}
Object.defineProperty(window, 'ClipboardItem', {
configurable: true,
value: TestClipboardItem,
});
Object.defineProperty(navigator, 'clipboard', {
configurable: true,
value: {
write: async () => undefined,
writeText: async () => undefined,
},
});
});
await routeMockAgents(page);
const projectId = await createEmptyProject(page, 'Preview tools smoke');
await seedHtmlArtifact(page, projectId, 'preview-tools.html', withSnapshotBridge(manualEditHtml()));
const conversationId = await latestConversationId(page, projectId);
await page.goto(`/projects/${projectId}/conversations/${conversationId}/files/preview-tools.html`);
await openDesignFile(page, 'preview-tools.html');
await expect(artifactPreview(page)).toBeVisible();
await expect(artifactPreviewFrame(page).getByRole('heading', { name: 'Original Hero' })).toBeVisible();
// The screenshot step is gone: `screenshot-copy-button` no longer exists in
// apps/web, and FileViewer's own suite asserts its absence. Comments, mark
// and edit below are still live, so the rest of this spec stands.
await clickPreviewToolbarAction(page, 'board-mode-toggle', /^Comment$/);
await expect(page.getByTestId('board-mode-toggle')).toHaveAttribute('aria-pressed', 'true');
await artifactPreviewFrame(page).locator('[data-od-id="hero-title"]').click();
await expect(page.getByTestId('comment-popover')).toBeVisible();
await page.getByTestId('comment-popover-input').fill('Panel-level comment');
await page.getByTestId('comment-popover').getByRole('button', { name: /^Comment$/ }).click();
await expect(page.getByTestId('comment-saved-marker-hero-title')).toBeVisible();
await expect(page.getByTestId('comment-side-panel')).toHaveCount(0);
const commentsButton = page.getByTestId('comment-panel-toggle');
await clickPreviewToolbarAction(page, 'comment-panel-toggle', /^Comments \(\d+\)$/);
await expect(commentsButton).toHaveAttribute('aria-pressed', 'false');
await clickPreviewToolbarAction(page, 'comment-panel-toggle', /^Comments \(\d+\)$/);
await expect(page.getByTestId('comment-side-panel')).toBeVisible();
await expect(page.getByTestId('comment-side-panel')).toContainText('Panel-level comment');
await expect(commentsButton).toContainText('1');
await page.getByRole('button', { name: /hide comments/i }).click();
await expect(page.getByTestId('chat-composer')).toBeVisible();
await holdNextRunOpen(page);
await sendPrompt(page, 'Keep the current preview run active');
await expect(page.getByRole('button', { name: 'Stop' })).toBeVisible();
await clickPreviewToolbarAction(page, 'draw-overlay-toggle', /^Mark$/);
await expect(page.getByTestId('draw-overlay-toggle')).toHaveAttribute('aria-pressed', 'true');
await expect(page.getByRole('button', { name: 'Box select' })).toBeVisible();
await page.getByPlaceholder('Add a note for this mark').fill('Mark this hero crop');
const addToInputButton = page.getByRole('button', { name: 'Add to input' });
const queueButton = page.getByRole('button', { name: 'Queue' });
await expect(addToInputButton).toBeEnabled();
await expect(queueButton).toBeEnabled();
const previewBox = await artifactPreview(page).boundingBox();
expect(previewBox).not.toBeNull();
await page.mouse.move(previewBox!.x + 80, previewBox!.y + 80);
await page.mouse.down();
await page.mouse.move(previewBox!.x + 220, previewBox!.y + 170);
await page.mouse.up();
await queueButton.click();
const queuedStrip = page.getByTestId('chat-queued-send-strip');
await expect(queuedStrip).toBeVisible();
await expect(queuedStrip).toContainText('Mark this hero crop');
await expect(queuedStrip.getByTestId('chat-queued-send-row')).toHaveCount(1);
await clickPreviewToolbarAction(page, 'manual-edit-mode-toggle', /^Edit$/);
await expect(page.getByTestId('manual-edit-mode-toggle')).toHaveAttribute('aria-pressed', 'true');
await selectPreviewElementThroughBridge(page, artifactPreviewFrame(page), '[data-od-id="hero-title"]', 'Parameters');
await expect(page.locator('.manual-edit-modal')).toContainText('Hero title');
await expect(page.locator('.manual-edit-modal')).toContainText('Parameters');
await expect(page.getByRole('button', { name: /^Save$/ })).toBeVisible();
});
test('[P1] comment selection falls back from an empty od-id to its screen label in Chromium', async ({ page }) => {
await routeMockAgents(page);
const projectId = await createEmptyProject(page, 'Empty annotated selector');
await seedHtmlArtifact(page, projectId, 'empty-annotated-selector.html', emptyOdIdScreenLabelHtml());
const conversationId = await latestConversationId(page, projectId);
await page.goto(`/projects/${projectId}/conversations/${conversationId}/files/empty-annotated-selector.html`);
await openDesignFile(page, 'empty-annotated-selector.html');
await clickPreviewToolbarAction(page, 'board-mode-toggle', /^Comment$/);
const frame = artifactPreviewFrame(page);
const target = frame.locator('[data-screen-label="Home"]');
await expect(target).toBeVisible();
await expect(target).toHaveAttribute('data-od-id', '');
await target.click();
const popover = page.getByTestId('comment-popover');
await expect(popover).toBeVisible();
await popover.getByTestId('comment-popover-input').fill('Keep this screen label anchored');
const savedRequest = page.waitForRequest((request) => {
const url = new URL(request.url());
return request.method() === 'POST' && url.pathname.endsWith(`/conversations/${conversationId}/comments`);
});
const savedResponse = page.waitForResponse((response) => {
const url = new URL(response.url());
return response.request().method() === 'POST' && url.pathname.endsWith(`/conversations/${conversationId}/comments`);
});
await popover.getByRole('button', { name: /^Comment$/ }).click();
expect((await savedResponse).ok()).toBe(true);
const body = (await savedRequest).postDataJSON() as { target?: { elementId?: string; selector?: string } };
expect(body.target).toMatchObject({
elementId: 'Home',
selector: '[data-screen-label="Home"]',
});
expect(await target.evaluate((element, selector) => (
document.querySelector(selector) === element
), body.target?.selector ?? '')).toBe(true);
});
test('[P1] draw annotation composer floats near the selected mark and can be queued', async ({ page }) => {
test.setTimeout(60_000);
await routeMockAgents(page);
const projectId = await createEmptyProject(page, 'Draw composer position smoke');
await seedHtmlArtifact(page, projectId, 'draw-position.html', withSnapshotBridge(manualEditHtml()));
const conversationId = await latestConversationId(page, projectId);
await page.goto(`/projects/${projectId}/conversations/${conversationId}/files/draw-position.html`);
await openDesignFile(page, 'draw-position.html');
await clickPreviewToolbarAction(page, 'board-mode-toggle', /^Comment$/);
await expect(page.getByTestId('board-mode-toggle')).toHaveAttribute('aria-pressed', 'true');
await holdNextRunOpen(page);
await sendPrompt(page, 'Keep draw queue mode active');
await expect(page.getByRole('button', { name: 'Stop' })).toBeVisible();
await clickPreviewToolbarAction(page, 'draw-overlay-toggle', /^Mark$/);
await expect(page.getByTestId('draw-overlay-toggle')).toHaveAttribute('aria-pressed', 'true');
const previewBox = await artifactPreview(page).boundingBox();
expect(previewBox).not.toBeNull();
const mark = {
x1: previewBox!.x + 120,
y1: previewBox!.y + 96,
x2: previewBox!.x + 300,
y2: previewBox!.y + 190,
};
await page.mouse.move(mark.x1, mark.y1);
await page.mouse.down();
await page.mouse.move(mark.x2, mark.y2);
await page.mouse.up();
const noteInput = page.locator('.preview-draw-note-input');
await expect(noteInput).toBeVisible();
const noteBox = await noteInput.boundingBox();
expect(noteBox).not.toBeNull();
expect(Math.abs(noteBox!.x - mark.x2)).toBeLessThan(260);
expect(Math.abs(noteBox!.y - mark.y2)).toBeLessThan(220);
await noteInput.fill('Float this note near the marked hero area');
const queueButton = page.getByRole('button', { name: 'Queue' });
await expect(queueButton).toBeEnabled();
await queueButton.click();
const queuedStrip = page.getByTestId('chat-queued-send-strip');
await expect(queuedStrip).toBeVisible();
await expect(queuedStrip).toContainText('Float this note near the marked hero area');
await expect(queuedStrip.getByTestId('chat-queued-send-row')).toHaveCount(1);
});
test('[P1] first-loop onboarding completes once after a successful artifact export', async ({ page }) => {
test.setTimeout(60_000);
const analyticsBodies: string[] = [];
const analyticsConfig = {
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: true, content: false, artifactManifest: false },
};
await page.addInitScript(
({ key, value }) => {
window.localStorage.setItem(key, JSON.stringify(value));
},
{ key: STORAGE_KEY, value: analyticsConfig },
);
await page.route('**/api/app-config', async (route) => {
if (route.request().method() === 'GET') {
await route.fulfill({ json: { config: analyticsConfig } });
return;
}
await route.continue();
});
await page.route('**/api/analytics/config', async (route) => {
await route.fulfill({
json: {
enabled: true,
env: 'e2e',
key: 'phc_e2e',
host: 'https://analytics.open-design.test',
installationId: 'e2e-installation',
},
});
});
await page.route('https://analytics.open-design.test/**', async (route) => {
analyticsBodies.push(route.request().postData() ?? '');
await route.fulfill({ status: 200, json: { status: 1 } });
});
await routeMockAgents(page);
const projectId = await createEmptyProject(page, 'First loop export smoke');
await seedHtmlArtifact(page, projectId, 'first-loop-export.html', manualEditHtml());
await page.addInitScript(
({ id }) => {
window.sessionStorage.setItem(
`open-design:first-loop-entry:${id}`,
JSON.stringify({
source: 'home_recommendation',
productType: 'prototype',
recommendationId: 'e2e-recommendation-card',
}),
);
window.sessionStorage.setItem(
`open-design:first-loop-steps:${id}`,
JSON.stringify(['prompt_sent', 'generated', 'artifact_viewed']),
);
},
{ id: projectId },
);
await page.goto(`/projects/${projectId}/files/first-loop-export.html`);
await openDesignFile(page, 'first-loop-export.html');
const shareMenu = await openShareExportMenu(page);
const [download] = await Promise.all([
page.waitForEvent('download'),
shareMenu.getByRole('menuitem', { name: /Export as standalone HTML/ }).click(),
]);
expect(download.suggestedFilename()).toMatch(/first-loop-export.*\.html$/i);
await expect.poll(() => analyticsBodies.join('\n'), { timeout: 15_000 }).toContain('onboarding_completed');
const raw = analyticsBodies.join('\n');
expect(raw).toContain('home_recommendation');
expect(raw).toContain('e2e-recommendation-card');
expect(raw).toContain('prompt_sent');
expect(raw).toContain('generated');
expect(raw).toContain('artifact_viewed');
expect(raw).toContain('delivered');
await openShareExportMenu(page);
await Promise.all([
page.waitForEvent('download'),
shareMenu.getByRole('menuitem', { name: /Export as standalone HTML/ }).click(),
]);
await expectStableCount(
() => analyticsBodies.join('\n').match(/onboarding_completed/g)?.length ?? 0,
1,
{
timeout: 750,
message: 're-exporting the same first-loop artifact should not emit a duplicate completion event',
},
);
});
test('[P0] manual edit mode keeps deck navigation available for deck-shaped HTML', async ({ page }) => {
await routeMockAgents(page);
const projectId = await createEmptyProject(page, 'Manual edit deck smoke');
await seedDeckArtifact(page, projectId, 'manual-deck.html', 'Manual Deck', ['Slide One', 'Slide Two']);
await page.goto(`/projects/${projectId}/files/manual-deck.html`);
await openDesignFile(page, 'manual-deck.html');
const frame = artifactPreviewFrame(page);
await expect(frame.getByText('Slide One')).toBeVisible();
await clickDeckNextSlide(page);
await expect(frame.getByText('Slide Two')).toBeVisible();
});
test('[P0] deck presentation host exit remains usable after the sandboxed slide takes focus', async ({ page }) => {
await routeMockAgents(page);
const projectId = await createEmptyProject(page, 'Deck presentation exit smoke');
await seedDeckArtifact(page, projectId, 'present-exit.html', 'Present Exit', ['Slide One', 'Slide Two']);
await page.goto(`/projects/${projectId}/files/present-exit.html`);
await openDesignFile(page, 'present-exit.html');
await page.getByRole('button', { name: 'Present', exact: true }).click();
const popupPromise = page.waitForEvent('popup');
await page.getByRole('menuitem', { name: /^In this tab/i }).click();
const presenter = await popupPromise;
const overlay = page.locator('.present-overlay');
await expect(overlay).toBeVisible();
const presentedSlide = overlay.frameLocator('iframe[title="present"]');
const slideHeading = presentedSlide.getByRole('heading', { name: 'Slide One' });
await expect(slideHeading).toBeVisible();
await slideHeading.click();
await expect.poll(() => page.evaluate(() => document.activeElement?.tagName)).toBe('IFRAME');
const presenterClosed = presenter.waitForEvent('close');
await overlay.getByRole('button', { name: 'Exit presentation' }).click();
await expect(overlay).toHaveCount(0);
await presenterClosed;
expect(presenter.isClosed()).toBe(true);
});
test('[P1] deck thumbnail rail keeps complete 16:9 slides separated and aligned', async ({ page }) => {
await routeMockAgents(page);
const projectId = await createEmptyProject(page, 'Deck thumbnail rail layout');
await seedDeckArtifact(
page,
projectId,
'thumbnail-rail.html',
'Thumbnail Rail',
['Slide One', 'Slide Two', 'Slide Three'],
{ frameworkDeck: true },
);
await page.goto(`/projects/${projectId}/files/thumbnail-rail.html`);
await openDesignFile(page, 'thumbnail-rail.html');
const rail = page.locator('.deck-thumbnail-rail');
const frames = rail.locator('.deck-thumbnail-frame');
const numbers = rail.locator('.deck-thumbnail-number');
await expect(rail).toBeVisible();
await expect(frames).toHaveCount(3);
const [railBox, firstFrame, secondFrame, firstNumber] = await Promise.all([
rail.boundingBox(),
frames.nth(0).boundingBox(),
frames.nth(1).boundingBox(),
numbers.nth(0).boundingBox(),
]);
expect(railBox).not.toBeNull();
expect(firstFrame).not.toBeNull();
expect(secondFrame).not.toBeNull();
expect(firstNumber).not.toBeNull();
if (!railBox || !firstFrame || !secondFrame || !firstNumber) return;
expect(firstFrame.width / firstFrame.height).toBeCloseTo(16 / 9, 1);
expect(secondFrame.width / secondFrame.height).toBeCloseTo(16 / 9, 1);
expect(secondFrame.y).toBeGreaterThanOrEqual(firstFrame.y + firstFrame.height + 8);
expect(firstNumber.y).toBeGreaterThanOrEqual(firstFrame.y);
expect(firstNumber.y).toBeLessThan(firstFrame.y + firstFrame.height);
expect(firstFrame.x + firstFrame.width).toBeLessThanOrEqual(
railBox.x + railBox.width,
);
});
test('[P0] @critical edited HTML file restores selected tab and preview after reload', async ({ page }) => {
await routeMockAgents(page);
const projectId = await createEmptyProject(page, 'File edit restore smoke');
await seedHtmlArtifact(page, projectId, 'restore-edit.html', manualEditHtml());
await seedHtmlArtifact(
page,
projectId,
'secondary-preview.html',
'<!doctype html><html><body><main><h1>Secondary Preview</h1></main></body></html>',
);
await page.goto(`/projects/${projectId}/files/secondary-preview.html`);
await openDesignFile(page, 'secondary-preview.html');
await expect(tabBySuffix(page, 'secondary-preview.html')).toHaveAttribute('aria-selected', 'true');
await openAllProjectFiles(page);
await openDesignFile(page, 'restore-edit.html');
const restoreTab = tabBySuffix(page, 'restore-edit.html');
const secondaryTab = tabBySuffix(page, 'secondary-preview.html');
await expect(restoreTab).toBeVisible();
await expect(restoreTab).toHaveAttribute('aria-selected', 'true');
await expect(secondaryTab).toBeVisible();
await expect(secondaryTab).toHaveAttribute('aria-selected', 'false');
const frame = artifactPreviewFrame(page);
await expect(frame.getByRole('heading', { name: 'Original Hero' })).toBeVisible();
await clickPreviewToolbarAction(page, 'manual-edit-mode-toggle', /^Edit$/);
await selectPreviewElementThroughBridge(page, frame, '[data-od-id="hero-title"]', 'Parameters');
const parameters = inspectorSection(page, 'Parameters');
const fontSizeInput = parameters.locator('.cc-row').filter({ hasText: 'Font size' }).locator('input');
await expect(fontSizeInput).toBeVisible();
await fontSizeInput.fill('52');
await parameters.locator('.cc-row').filter({ hasText: 'Text color' }).locator('input:not([type="color"])').fill('#2563eb');
await inspectSaveButton(page).click({ force: true });
await expectFileSource(page, projectId, 'restore-edit.html', ['font-size: 52px', 'color:']);
await clickPreviewToolbarAction(page, 'manual-edit-mode-toggle', /^Edit$/);
const viewMode = page.getByRole('tablist', { name: 'View mode' });
await expect(viewMode).toBeVisible();
await expect(viewMode.getByRole('tab', { name: 'Preview', exact: true })).toHaveAttribute('aria-selected', 'true');
await expect(viewMode.getByRole('tab', { name: 'Code', exact: true })).toBeVisible();
await expect(page.locator('.viewer-source')).toHaveCount(0);
await expect(restoreTab).toHaveAttribute('aria-selected', 'true');
await expect(secondaryTab).toHaveAttribute('aria-selected', 'false');
await page.reload();
await waitForLoadingToClear(page);
await expect(page.getByTestId('file-workspace')).toBeVisible();
const restoredTab = tabBySuffix(page, 'restore-edit.html');
await expect(restoredTab).toBeVisible();
await expect(restoredTab).toHaveAttribute('aria-selected', 'true');
const restoredViewMode = page.getByRole('tablist', { name: 'View mode' });
await expect(restoredViewMode).toBeVisible();
await expect(restoredViewMode.getByRole('tab', { name: 'Preview', exact: true })).toHaveAttribute('aria-selected', 'true');
await expect(restoredViewMode.getByRole('tab', { name: 'Code', exact: true })).toBeVisible();
await expect(page.locator('.viewer-source')).toHaveCount(0);
await expect(artifactPreview(page)).toBeVisible();
const restoredFrame = artifactPreviewFrame(page);
const restoredTitle = restoredFrame.getByRole('heading', { name: 'Original Hero' });
await expect(restoredTitle).toBeVisible();
await expect(restoredTitle).toHaveCSS('font-size', '52px');
await expect(restoredTitle).toHaveCSS('color', 'rgb(37, 99, 235)');
});
async function routeMockAgents(page: Page) {
await routeAgents(page, [
{
id: 'mock',
name: 'Mock Agent',
bin: 'mock-agent',
available: true,
version: 'test',
models: [{ id: 'default', label: 'Default' }],
},
]);
}
async function createEmptyProject(page: Page, name: string): Promise<string> {
const projectId = await createProjectViaApi(page, name);
await page.goto(`/projects/${projectId}`, { waitUntil: 'domcontentloaded' });
await waitForLoadingToClear(page).catch(() => {});
await expect(page.getByTestId('file-workspace')).toBeVisible();
return projectId;
}
// Export is its own header button now — no Share detour, no tab strip. The
// popover shell is still shared with Share, so the locator is unchanged.
async function openShareExportMenu(page: Page): Promise<ReturnType<Page['locator']>> {
await page.getByRole('button', { name: /^Export$/ }).click();
const menu = page.locator('.share-menu-popover[role="menu"]');
await expect(menu).toBeVisible();
await expect(menu.getByRole('menuitem', { name: /^Export as/ }).first()).toBeVisible();
return menu;
}
async function previewCss(page: Page, selector: string, property: keyof CSSStyleDeclaration): Promise<string> {
return artifactPreviewFrame(page)
.locator(selector)
.evaluate((el, cssProperty) => String(getComputedStyle(el)[cssProperty as keyof CSSStyleDeclaration] ?? ''), property)
.catch(() => '');
}
async function createProjectViaApi(page: Page, name: string): Promise<string> {
await gotoEntryHome(page);
const id = `playwright-export-${Date.now()}`;
const response = await page.request.post('/api/projects', {
data: {
id,
name,
skillId: null,
designSystemId: null,
metadata: { kind: 'prototype' },
},
timeout: 15_000,
});
expect(response.ok()).toBeTruthy();
const body = (await response.json()) as { project?: { id?: string } };
const projectId = body.project?.id;
if (!projectId) throw new Error(`project create response missing id: ${JSON.stringify(body)}`);
return projectId;
}
async function gotoEntryHome(page: Page) {
for (let attempt = 0; attempt < 3; attempt += 1) {
try {
await page.goto('/', { waitUntil: 'domcontentloaded' });
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
if (!/ERR_ABORTED|frame was detached/i.test(message)) throw error;
}
await waitForLoadingToClear(page).catch(() => {});
if (await page.getByTestId('home-hero').isVisible({ timeout: 3_000 }).catch(() => false)) break;
}
const privacyDialog = page.getByRole('dialog').filter({ hasText: 'Help us improve OpenDesign' });
if (await privacyDialog.isVisible()) {
await privacyDialog.getByRole('button', { name: /I get it|not now|got it|don't share/i }).click();
await expect(privacyDialog).toHaveCount(0);
}
await expect(page.getByTestId('home-hero')).toBeVisible();
await expect(page.getByTestId('home-hero-input')).toBeVisible();
}
async function seedHtmlArtifact(page: Page, projectId: string, fileName: string, content: string) {
const resp = await page.request.post(
`/api/projects/${projectId}/files`,
{
data: {
name: fileName,
content,
artifactManifest: {
version: 1,
kind: 'html',
title: fileName,
entry: fileName,
renderer: 'html',
exports: ['html'],
},
},
timeout: 15_000,
},
);
expect(resp.ok()).toBeTruthy();
}
async function seedProjectFile(page: Page, projectId: string, fileName: string, content: string) {
const response = await page.request.post(`/api/projects/${projectId}/files`, {
data: { name: fileName, content },
timeout: 15_000,
});
expect(response.ok()).toBeTruthy();
}
async function latestConversationId(page: Page, projectId: string): Promise<string> {
const response = await page.request.get(`/api/projects/${projectId}/conversations`, { timeout: 15_000 });
expect(response.ok()).toBeTruthy();
const { conversations } = (await response.json()) as {
conversations: Array<{ id: string; updatedAt: number }>;
};
const latest = [...conversations].sort((a, b) => b.updatedAt - a.updatedAt)[0];
if (!latest) throw new Error(`no conversations found for project ${projectId}`);
return latest.id;
}
async function holdNextRunOpen(page: Page) {
await routeSuccessfulRuns(page, {
runIdPrefix: 'preview-tools-run',
events: 'pending',
});
}
async function sendPrompt(page: Page, prompt: string) {
const input = page.getByTestId('chat-composer-input');
const sendButton = page.getByTestId('chat-send');
await expect(input).toBeVisible({ timeout: T.short });
await input.click();
await input.fill(prompt);
await expect(input).toHaveText(prompt, { timeout: T.short });
await expect(sendButton).toBeEnabled({ timeout: T.short });
await Promise.all([
page.waitForResponse(isCreateRunResponse, { timeout: 5_000 }),
sendButton.evaluate((button: HTMLButtonElement) => button.click()),
]);
}
function isCreateRunResponse(resp: { url(): string; request(): { method(): string } }): boolean {
const url = new URL(resp.url());
return url.pathname === '/api/runs' && resp.request().method() === 'POST';
}
function withSnapshotBridge(html: string): string {
const bridge = `
<script>
window.addEventListener('message', (event) => {
const data = event.data || {};
if (data.type !== 'od:snapshot') return;
event.source?.postMessage({
type: 'od:snapshot:result',
id: data.id,
dataUrl: 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII=',
w: 1,
h: 1,
}, '*');
});
</script>`;
return html.replace('</body>', `${bridge}</body>`);
}
function poweredWebglHtml(): string {
return `<!doctype html>
<html>
<head>
<title>Powered WebGL Smoke</title>
<style>
body { margin: 0; min-height: 100vh; display: grid; place-items: center; background: #0f172a; color: #f8fafc; font-family: sans-serif; }
main { display: grid; gap: 12px; justify-items: center; }
canvas { width: 160px; height: 96px; border: 1px solid #38bdf8; background: #111827; }
</style>
</head>
<body>
<main>
<h1>Powered WebGL Smoke</h1>
<canvas id="scene" width="160" height="96"></canvas>
<p data-testid="powered-status">booting</p>
</main>
<script>
document.createElement('canvas').getContext('webgl2');
const canvas = document.getElementById('scene');
const ctx = canvas.getContext('2d');
ctx.fillStyle = '#22c55e';
ctx.fillRect(16, 20, 128, 56);
ctx.fillStyle = '#0f172a';
ctx.font = '18px sans-serif';
ctx.fillText('OD', 66, 55);
document.querySelector('[data-testid="powered-status"]').textContent =
window.crossOriginIsolated ? 'isolated' : 'not-isolated';
</script>
</body>
</html>`;
}
async function seedDeckArtifact(
page: Page,
projectId: string,
fileName: string,
title: string,
slides: string[],
options: {
selfHandlesSlideMessages?: boolean;
mentionsSlideMessageProtocol?: boolean;
stopsSlideMessagePropagation?: boolean;
handlesKeyboard?: boolean;
frameworkDeck?: boolean;
} = {},
) {
const slideHtml = slides
.map((slide, index) => `<section class="slide" data-od-id="slide-${index + 1}"${index === 0 ? '' : ' hidden'}><h1>${slide}</h1></section>`)
.join('\n');
const deckHtml = options.frameworkDeck
? `<div class="deck-stage" id="deck-stage">${slideHtml}</div>`
: slideHtml;
const deckChrome = options.stopsSlideMessagePropagation
? '<nav><span id="deck-cur">01</span> / <span id="deck-total">03</span></nav>'
: '';
const slideScript =
options.selfHandlesSlideMessages || options.stopsSlideMessagePropagation || options.handlesKeyboard
? `<script>
(() => {
let active = 0;
const slides = Array.from(document.querySelectorAll('.slide'));
function render() {
slides.forEach((slide, index) => {
slide.style.display = index === active ? '' : 'none';
slide.toggleAttribute('hidden', index !== active);
});
const cur = document.getElementById('deck-cur');
const total = document.getElementById('deck-total');
if (cur) cur.textContent = String(active + 1).padStart(2, '0');
if (total) total.textContent = String(slides.length).padStart(2, '0');
}
window.addEventListener('message', (event) => {
if (!event.data || event.data.type !== 'od:slide') return;
${options.stopsSlideMessagePropagation ? 'event.stopImmediatePropagation();' : ''}
if (event.data.action === 'next') active = Math.min(slides.length - 1, active + 1);
if (event.data.action === 'prev') active = Math.max(0, active - 1);
if (event.data.action === 'first') active = 0;
if (event.data.action === 'last') active = slides.length - 1;
if (event.data.action === 'go' && typeof event.data.index === 'number') {
active = Math.max(0, Math.min(slides.length - 1, event.data.index));
}
render();
${options.stopsSlideMessagePropagation ? '' : "window.parent.postMessage({ type: 'od:slide-state', active, count: slides.length }, '*');"}
});
${
options.handlesKeyboard
? `function onKey(event) {
if (event.key !== 'ArrowRight') return;
event.preventDefault();
active = Math.min(slides.length - 1, active + 1);
render();
}
window.addEventListener('keydown', onKey, true);
document.addEventListener('keydown', onKey, true);
document.body.setAttribute('tabindex', '-1');
document.body.focus();`
: ''
}
render();
${options.stopsSlideMessagePropagation ? '' : "window.parent.postMessage({ type: 'od:slide-state', active, count: slides.length }, '*');"}
})();
</script>`
: '';
const protocolText = options.mentionsSlideMessageProtocol
? '<p>Protocol token: od:slide</p>'
: '';
const resp = await page.request.post(
`/api/projects/${projectId}/files`,
{
data: {
name: fileName,
content: `<!doctype html><html><body>${deckChrome}${deckHtml}${protocolText}${slideScript}</body></html>`,
artifactManifest: {
version: 1,
kind: 'deck',
title,
entry: fileName,
renderer: 'deck-html',
exports: ['html', 'pdf'],
},
},
timeout: 15_000,
},
);
expect(resp.ok()).toBeTruthy();
}
async function openDesignFile(page: Page, fileName: string) {
const preview = artifactPreview(page);
await waitForLoadingToClear(page).catch(() => {});
const activePath = new URL(page.url()).pathname;
if (activePath.endsWith(`/files/${encodeURIComponent(fileName)}`)) {
await expect(preview).toBeVisible();
return;
}
const filePattern = new RegExp(fileName.replace(/\./g, '\\.'), 'i');
const fileTabButton = page.getByRole('tab', { name: filePattern }).first();
let tabFound = true;
try {
await fileTabButton.waitFor({ state: 'visible', timeout: 5_000 });
} catch {
tabFound = false;
}
if (tabFound) {
const isSelected = await fileTabButton.getAttribute('aria-selected');
if (isSelected !== 'true') {
await fileTabButton.click();
}
} else {
// #5517 deleted the design-file preview pane and its "Open" button: the
// Design Files row's primary target opens the file in a workspace tab on
// a single click, so the preview renders straight away.
const fileRow = page
.locator(`[data-testid^="design-file-row-"][data-testid$="${fileName}"]`)
.first();
await expect(fileRow).toBeVisible();
await fileRow.getByRole('button').first().click();
}
await expect(preview).toBeVisible();
}
async function waitForLoadingToClear(page: Page) {
await page.getByText('Loading OpenDesign…').waitFor({ state: 'hidden', timeout: T.long });
}
async function expectFileSource(page: Page, projectId: string, fileName: string, snippets: string[]) {
await expect
.poll(async () => {
const resp = await page.request.get(`/api/projects/${projectId}/files/${fileName}`);
if (!resp.ok()) return false;
const source = await resp.text();
return snippets.every((snippet) => source.includes(snippet));
})
.toBe(true);
}
async function expectFileSourceExcludes(page: Page, projectId: string, fileName: string, snippets: string[]) {
await expect
.poll(async () => {
const resp = await page.request.get(`/api/projects/${projectId}/files/${fileName}`);
if (!resp.ok()) return false;
const source = await resp.text();
return snippets.every((snippet) => !source.includes(snippet));
})
.toBe(true);
}
function inspectorRow(page: Page, label: string) {
return page.locator('.manual-edit-modal .cc-row').filter({ hasText: label }).first();
}
function inspectorSection(page: Page, title: string) {
return page.locator('.manual-edit-modal .cc-section').filter({ hasText: title }).first();
}
function inspectSaveButton(page: Page) {
return page.locator('.manual-edit-modal').getByRole('button', { name: /^Save$/ });
}
function tabBySuffix(page: Page, name: string) {
return page
.getByRole('tab')
.filter({
hasText: new RegExp(`${escapeRegExp(name)}$`),
})
.first();
}
function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
function emptyOdIdScreenLabelHtml(): string {
return `<!doctype html>
<html>
<body>
<main>
<section data-od-id="" data-screen-label="Home" style="width:320px;min-height:80px;padding:24px">
Empty id, Home screen label
</section>
</main>
</body>
</html>`;
}
function manualEditHtml(): string {
return `<!doctype html>
<html>
<head>
<meta charset="utf-8">
<title>Manual Edit</title>
<style>
.responsive-pair { display: flex; gap: 24px; }
.responsive-pair > div { flex: 1 1 0; min-height: 40px; }
@media (max-width: 700px) {
.responsive-pair { flex-direction: column; }
}
</style>
</head>
<body style="font-family: Inter, system-ui, sans-serif; font-size: 16px; letter-spacing: 0.01em;">
<main>
<section data-od-id="responsive-pair" data-od-label="Responsive pair" class="responsive-pair">
<div data-od-id="pair-a">Left panel</div>
<div data-od-id="pair-b">Right panel</div>
</section>
<section data-od-id="hero" data-od-label="Hero section" style="display:flex;gap:8px;align-items:center;">
<h1 data-od-id="hero-title" data-od-label="Hero title">Original Hero</h1>
<a data-od-id="cta" data-od-label="Primary CTA" href="/start">Start now</a>
<img data-od-id="hero-image" data-od-label="Hero image" src="/hero.png" alt="Hero" style="width:64px;height:64px;">
</section>
</main>
</body>
</html>`;
}
function multiPageMobileHtml(): string {
return `<!doctype html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<style>
body { margin: 0; font-family: sans-serif; }
main { min-height: 480px; padding: 32px; }
nav { display: flex; gap: 12px; padding: 16px 32px; }
[hidden] { display: none !important; }
</style>
</head>
<body>
<main data-testid="mobile-page-home" data-od-id="mobile-page-home" data-page="home">
<h1>Home page</h1>
</main>
<main data-testid="mobile-page-profile" data-od-id="mobile-page-profile" data-page="profile" hidden>
<h1>Profile page</h1>
</main>
<nav aria-label="Mobile navigation">
<button type="button" data-target-page="home">Home</button>
<button type="button" data-target-page="profile">Profile</button>
</nav>
<script>
document.querySelectorAll('[data-target-page]').forEach((button) => {
button.addEventListener('click', () => {
const target = button.getAttribute('data-target-page');
document.querySelectorAll('[data-page]').forEach((page) => {
page.toggleAttribute('hidden', page.getAttribute('data-page') !== target);
});
});
});
</script>
</body>
</html>`;
}
function runtimeRenderedMobileHtml(): string {
return `<!doctype html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
</head>
<body>
<div id="app"></div>
<script>
const app = document.querySelector('#app');
const renderToday = () => {
app.innerHTML =
'<main data-testid="mobile-page-today" data-od-id="today-screen">' +
'<h1>Today page</h1>' +
'<button type="button" data-page-target="profile">Profile</button>' +
'</main>';
};
const renderProfile = () => {
app.innerHTML =
'<main data-testid="mobile-page-profile" data-od-id="profile-screen">' +
'<section><h1>Profile page</h1><p>Current page content</p></section>' +
'<button type="button" data-page-target="today">Today</button>' +
'</main>';
};
document.addEventListener('click', (event) => {
if (event.target.closest('[data-page-target="profile"]')) renderProfile();
if (event.target.closest('[data-page-target="today"]')) renderToday();
});
renderToday();
</script>
</body>
</html>`;
}
function linkedMobilePageHtml(
heading: string,
screenId: string,
href: string,
linkLabel: string,
): string {
return `<!doctype html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
</head>
<body>
<main data-od-id="${screenId}">
<h1>${heading}</h1>
<a href="${href}">${linkLabel}</a>
</main>
</body>
</html>`;
}