From 3d31a1ff17b0c8b2b7d5abfac3b40034e5bc722a Mon Sep 17 00:00:00 2001 From: Brian Love Date: Sat, 10 Oct 2026 22:07:03 -0700 Subject: [PATCH 1/2] fix(website): keep announcements clear of active Run controls --- .../e2e/workspace-run-announcement.spec.ts | 342 ++++++++++++++++++ apps/website/e2e/workspace-shell.spec.ts | 7 +- apps/website/src/styles/chrome.css | 4 + 3 files changed, 352 insertions(+), 1 deletion(-) create mode 100644 apps/website/e2e/workspace-run-announcement.spec.ts diff --git a/apps/website/e2e/workspace-run-announcement.spec.ts b/apps/website/e2e/workspace-run-announcement.spec.ts new file mode 100644 index 000000000..7c30f7bfc --- /dev/null +++ b/apps/website/e2e/workspace-run-announcement.spec.ts @@ -0,0 +1,342 @@ +import { expect, test, type Page } from '@playwright/test'; + +const toast = (page: Page) => page.locator('[data-announcement-toast]'); +const region = (page: Page) => page.locator('[data-announcement-region]'); +const shell = (page: Page) => page.locator('[data-workspace-shell]'); +const violations = new WeakMap(); + +test.skip( + ({ baseURL }) => + !baseURL || !['localhost', '127.0.0.1'].includes(new URL(baseURL).hostname), + 'Requires owned local static native previews; never run against a deployed Website.' +); + +async function mode(page: Page, name: 'Docs' | 'Code' | 'API' | 'Run') { + const mobile = (page.viewportSize()?.width ?? 1280) < 768; + if (mobile) + await page.getByRole('button', { name: 'Open navigation' }).click(); + const navigation = mobile + ? page.getByRole('dialog', { name: 'Documentation control plane' }) + : page.locator('[data-workspace-desktop-navigation]'); + await navigation + .getByRole('button', { + name: name === 'Run' ? /^Run(?:,|$)/ : name, + exact: name !== 'Run', + }) + .click(); + await expect(shell(page)).toHaveAttribute('data-workspace-mode', name); + if (mobile) await expect(navigation).toHaveCount(0); +} + +// Workspace locks document scrolling. Exercise the real scroll listener with a +// temporary inert layout fixture, then restore geometry before any UI assertion. +async function eligible(page: Page, expectMounted = true) { + const original = await page.evaluate(() => { + const html = document.documentElement; + const body = document.body; + const saved = { + html: html.getAttribute('style'), + body: body.getAttribute('style'), + x: scrollX, + y: scrollY, + height: html.scrollHeight, + width: html.scrollWidth, + }; + html.style.setProperty('overflow', 'auto', 'important'); + html.style.setProperty('height', 'auto', 'important'); + body.style.setProperty('overflow', 'visible', 'important'); + body.style.setProperty('height', 'auto', 'important'); + const spacer = document.createElement('div'); + spacer.id = 'announcement-scroll-fixture'; + spacer.inert = true; + spacer.style.height = `${innerHeight * 4}px`; + body.append(spacer); + const range = html.scrollHeight - innerHeight; + window.scrollTo(0, range * 0.5); + return { ...saved, range }; + }); + expect(original.range).toBeGreaterThan(0); + await expect + .poll(() => + page.evaluate( + () => scrollY / (document.documentElement.scrollHeight - innerHeight) + ) + ) + .toBeGreaterThanOrEqual(0.4); + await page.clock.runFor(100); + await page.clock.fastForward(30_100); + await page.clock.runFor(100); + if (expectMounted) + await expect(toast(page)).toHaveAttribute('data-mounted', 'true'); + await page.evaluate((saved) => { + document.getElementById('announcement-scroll-fixture')?.remove(); + for (const [element, value] of [ + [document.documentElement, saved.html], + [document.body, saved.body], + ] as const) { + if (value === null) element.removeAttribute('style'); + else element.setAttribute('style', value); + } + window.scrollTo({ left: saved.x, top: saved.y, behavior: 'instant' }); + }, original); + await page.clock.runFor(300); + const restored = await page.evaluate((saved) => { + window.scrollTo({ left: saved.x, top: saved.y, behavior: 'instant' }); + for (const [element, value] of [ + [document.documentElement, saved.html], + [document.body, saved.body], + ] as const) { + element.style.cssText = value ?? ''; + // Flush Chromium's lazy CSSOM attribute serialization before removing + // an originally absent style attribute. + element.getAttribute('style'); + if (value === null) element.removeAttribute('style'); + else element.setAttribute('style', value); + } + return { + html: document.documentElement.getAttribute('style'), + body: document.body.getAttribute('style'), + x: scrollX, + y: scrollY, + height: document.documentElement.scrollHeight, + width: document.documentElement.scrollWidth, + }; + }, original); + expect(restored).toEqual({ + html: original.html, + body: original.body, + x: original.x, + y: original.y, + height: original.height, + width: original.width, + }); + await expect(page.locator('#announcement-scroll-fixture')).toHaveCount(0); + await expect(shell(page)).toHaveAttribute('data-hydrated', 'true'); +} + +async function hidden(page: Page) { + await expect(toast(page)).toHaveCount(1); + await expect(region(page)).toBeHidden(); + await expect(toast(page)).toBeHidden(); + expect( + await region(page).evaluate((element) => getComputedStyle(element).display) + ).toBe('none'); + await expect( + page.getByRole('dialog', { name: 'From Prototype to Production' }) + ).toHaveCount(0); + expect(await region(page).ariaSnapshot()).toBe(''); + expect( + await region(page).evaluate((element) => element.getClientRects().length) + ).toBe(0); + await toast(page) + .getByRole('button', { name: 'Dismiss', exact: true, includeHidden: true }) + .evaluate((element) => (element as HTMLElement).focus()); + expect( + await page.evaluate( + () => !!document.activeElement?.closest('[data-announcement-region]') + ) + ).toBe(false); + for (let index = 0; index < 12; index++) { + await page.keyboard.press('Tab'); + expect( + await page.evaluate( + () => !!document.activeElement?.closest('[data-announcement-region]') + ) + ).toBe(false); + } +} + +test.beforeEach(async ({ page, context }) => { + // No public transport or authoring: previews are local static builds/fixtures. + const unexpected: string[] = []; + violations.set(page, unexpected); + await context.route('**/*', async (route) => { + const request = route.request(); + const url = new URL(request.url()); + if ( + !['localhost', '127.0.0.1'].includes(url.hostname) || + !['GET', 'HEAD'].includes(request.method()) + ) { + if ( + !['GET', 'HEAD'].includes(request.method()) || + /\/threads(?:\/|$)|\/api\/native(?:\/|$)|\/agent(?:\/|$)/.test( + url.pathname + ) + ) { + unexpected.push(`${request.method()} ${url.origin}${url.pathname}`); + } + await route.abort(); + } else await route.continue(); + }); + await page.clock.install(); +}); +test.afterEach(async ({ page }) => { + expect(violations.get(page)).toEqual([]); +}); + +for (const frontend of ['angular', 'react'] as const) { + const path = + frontend === 'angular' + ? '/docs/langgraph/guides/streaming' + : '/docs/deep-agents/capabilities/filesystem'; + for (const width of [1280, 390]) { + for (const direct of [false, true]) { + test(`${frontend} ${width} ${ + direct ? 'direct Run with late eligibility' : 'eligible Docs then Run' + } preserves native draft and history`, async ({ page }, testInfo) => { + await page.setViewportSize({ width, height: 844 }); + await page.goto( + `${path}?frontend=${frontend}${direct ? '&mode=run' : ''}` + ); + await expect(shell(page)).toHaveAttribute('data-hydrated', 'true'); + await expect(shell(page)).toHaveAttribute( + 'data-workspace-mode', + direct ? 'Run' : 'Docs' + ); + await eligible(page); + if (!direct) { + await expect(toast(page)).toBeVisible(); + await mode(page, 'Run'); + } + await hidden(page); + const iframe = page.locator('iframe'); + await expect(iframe).toBeVisible(); + await expect(iframe).toHaveAttribute( + 'src', + frontend === 'angular' ? /localhost:4300/ : /localhost:4629/ + ); + const identity = await iframe.elementHandle(); + const input = page.frameLocator('iframe').getByRole('textbox').first(); + await expect(input).toBeVisible(); + await input.click(); + await input.fill('Unsent announcement regression draft'); + await expect(input).toHaveValue('Unsent announcement regression draft'); + expect( + await input.evaluate((element) => { + const rect = element.getBoundingClientRect(); + return element.contains( + document.elementFromPoint( + rect.x + rect.width / 2, + rect.y + rect.height / 2 + ) + ); + }) + ).toBe(true); + await page.screenshot({ + path: testInfo.outputPath('run-announcement-hidden.png'), + }); + if (width === 390) { + await page.getByRole('button', { name: 'Open navigation' }).click(); + await expect(region(page)).toHaveAttribute( + 'data-workspace-modal-hidden', + '' + ); + await expect + .poll(() => + page.evaluate(() => + document.activeElement + ?.closest('[role="dialog"]') + ?.getAttribute('aria-label') + ) + ) + .toBe('Documentation control plane'); + await page.keyboard.press('Escape'); + await expect( + page.getByRole('dialog', { name: 'Documentation control plane' }) + ).toHaveCount(0); + await expect(region(page)).not.toHaveAttribute( + 'data-workspace-modal-hidden', + '' + ); + await hidden(page); + } + await mode(page, 'Code'); + await expect(toast(page)).toBeVisible(); + await page.goBack(); + await expect(shell(page)).toHaveAttribute('data-workspace-mode', 'Run'); + await hidden(page); + await expect(input).toHaveValue('Unsent announcement regression draft'); + expect( + await iframe.evaluate( + (element, original) => element === original, + identity + ) + ).toBe(true); + await page.goForward(); + await expect(shell(page)).toHaveAttribute( + 'data-workspace-mode', + 'Code' + ); + await expect(toast(page)).toBeVisible(); + for (const next of ['API', 'Docs'] as const) { + await mode(page, next); + await expect(toast(page)).toBeVisible(); + } + if (width === 390) { + await page.getByRole('button', { name: 'Open navigation' }).click(); + await expect(toast(page)).toBeHidden(); + await expect + .poll(() => + page.evaluate(() => + document.activeElement + ?.closest('[role="dialog"]') + ?.getAttribute('aria-label') + ) + ) + .toBe('Documentation control plane'); + await page.keyboard.press('Escape'); + await expect( + page.getByRole('dialog', { name: 'Documentation control plane' }) + ).toHaveCount(0); + await expect(toast(page)).toBeVisible(); + } + await toast(page) + .getByRole('button', { name: 'Dismiss', exact: true }) + .click(); + await mode(page, 'Run'); + await mode(page, 'Docs'); + await expect(toast(page)).toHaveCount(0); + await page.reload(); + await expect(shell(page)).toHaveAttribute('data-hydrated', 'true'); + await eligible(page, false); + await expect(toast(page)).toHaveCount(0); + }); + } + } +} + +for (const path of [ + '/docs/langgraph/getting-started/introduction?mode=run', + '/docs/langgraph/guides/streaming?mode=invalid', +]) { + test(`rendered Docs stays eligible at ${path}`, async ({ page }) => { + await page.goto(path); + await expect(shell(page)).toHaveAttribute('data-hydrated', 'true'); + await expect(shell(page)).toHaveAttribute('data-workspace-mode', 'Docs'); + await eligible(page); + await expect(toast(page)).toBeVisible(); + }); +} + +test('normal marketing page keeps natural timer and scroll eligibility', async ({ + page, +}) => { + await page.goto('/'); + await expect(page.locator('main')).toBeVisible(); + await page.clock.fastForward(31_000); + await expect(toast(page)).toHaveCount(0); + expect( + await page.evaluate( + () => document.documentElement.scrollHeight - innerHeight + ) + ).toBeGreaterThan(0); + await page.evaluate(() => + window.scrollTo( + 0, + (document.documentElement.scrollHeight - innerHeight) * 0.5 + ) + ); + await page.clock.runFor(500); + await expect(toast(page)).toBeVisible(); + await expect(toast(page)).toHaveAttribute('data-mounted', 'true'); +}); diff --git a/apps/website/e2e/workspace-shell.spec.ts b/apps/website/e2e/workspace-shell.spec.ts index 4030ac891..691bc49ec 100644 --- a/apps/website/e2e/workspace-shell.spec.ts +++ b/apps/website/e2e/workspace-shell.spec.ts @@ -669,9 +669,14 @@ test.describe('workspace shell', () => { 'data-workspace-modal-hidden', '' ); + await expect(announcement).toBeHidden(); + await expect(lateAnnouncement).toBeHidden(); + await expect(trigger).toBeFocused(); + await trigger.click(); + await dialog.getByRole('button', { name: 'Docs', exact: true }).click(); + await expectMode(page, 'Docs'); await expect(announcement).toBeVisible(); await expect(lateAnnouncement).toBeVisible(); - await expect(trigger).toBeFocused(); await announcement.evaluate((element) => element.remove()); await lateAnnouncement.evaluate((element) => element.remove()); }); diff --git a/apps/website/src/styles/chrome.css b/apps/website/src/styles/chrome.css index 529eb23e4..5a21ccd71 100644 --- a/apps/website/src/styles/chrome.css +++ b/apps/website/src/styles/chrome.css @@ -332,6 +332,10 @@ [data-announcement-region][data-workspace-modal-hidden] { display: none; } +/* Keep the announcement away from live controls in the actual active Run mode. */ +html:has([data-website-workspace-host] [data-workspace-shell][data-workspace-mode='Run']) [data-announcement-region] { + display: none; +} /* On phones the 360px right-hugging card read as a wall (327px of a * 375px screen, findings ยง6): present it as a centered bottom sheet * with even margins instead. */ From 3c774f79303c5e41be6f154188101acad26d375e Mon Sep 17 00:00:00 2001 From: Brian Love Date: Sat, 10 Oct 2026 23:01:17 -0700 Subject: [PATCH 2/2] test(website): synchronize announcement eligibility with hydration --- .../e2e/workspace-run-announcement.spec.ts | 44 ++++++++++++++++--- 1 file changed, 38 insertions(+), 6 deletions(-) diff --git a/apps/website/e2e/workspace-run-announcement.spec.ts b/apps/website/e2e/workspace-run-announcement.spec.ts index 7c30f7bfc..3c4980b7a 100644 --- a/apps/website/e2e/workspace-run-announcement.spec.ts +++ b/apps/website/e2e/workspace-run-announcement.spec.ts @@ -321,8 +321,25 @@ for (const path of [ test('normal marketing page keeps natural timer and scroll eligibility', async ({ page, }) => { + // Marketing main is server-rendered. Observe the real delay registration so + // the clock cannot advance past it before AnnouncementToast hydrates. + await page.addInitScript(() => { + const browserTimers: WindowOrWorkerGlobalScope = window; + const schedule = browserTimers.setTimeout.bind(window); + browserTimers.setTimeout = (handler, delay, ...args) => { + const id = schedule(handler, delay, ...args); + if (delay === 30_000) + Reflect.set(window, 'announcementTimerScheduled', true); + return id; + }; + }); await page.goto('/'); await expect(page.locator('main')).toBeVisible(); + await expect + .poll(() => + page.evaluate(() => Reflect.get(window, 'announcementTimerScheduled')) + ) + .toBe(true); await page.clock.fastForward(31_000); await expect(toast(page)).toHaveCount(0); expect( @@ -331,12 +348,27 @@ test('normal marketing page keeps natural timer and scroll eligibility', async ( ) ).toBeGreaterThan(0); await page.evaluate(() => - window.scrollTo( - 0, - (document.documentElement.scrollHeight - innerHeight) * 0.5 - ) + window.scrollTo({ + top: (document.documentElement.scrollHeight - innerHeight) * 0.5, + behavior: 'instant', + }) ); - await page.clock.runFor(500); + await expect + .poll(() => + page.evaluate( + () => scrollY / (document.documentElement.scrollHeight - innerHeight) + ) + ) + .toBeGreaterThanOrEqual(0.4); + // Scroll delivery, React's visibility effect, and its mount RAF are separate + // turns. Keep advancing real frames until the actual toast finishes mounting. + await expect + .poll(async () => { + await page.clock.runFor(100); + return toast(page).evaluateAll( + (elements) => elements[0]?.getAttribute('data-mounted') ?? null + ); + }) + .toBe('true'); await expect(toast(page)).toBeVisible(); - await expect(toast(page)).toHaveAttribute('data-mounted', 'true'); });