From 3d4dc5332bf75d762da21c173c682ca93f9b923a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Fri, 10 Oct 2025 19:16:53 +0200 Subject: [PATCH 01/11] Explore requiring a Playgrond name before saving --- .../website/src/components/layout/index.tsx | 4 + .../components/rename-site-modal/index.tsx | 3 +- .../save-site-to-local-modal/index.tsx | 137 ++++++++++++++++++ .../site-manager/site-info-panel/index.tsx | 54 ++++--- .../site-info-panel/style.module.css | 15 ++ .../site-persist-button/index.tsx | 27 ++-- .../lib/state/redux/persist-temporary-site.ts | 56 ++++--- 7 files changed, 247 insertions(+), 49 deletions(-) create mode 100644 packages/playground/website/src/components/save-site-to-local-modal/index.tsx diff --git a/packages/playground/website/src/components/layout/index.tsx b/packages/playground/website/src/components/layout/index.tsx index 1da56b7323..a3cc7a17b8 100644 --- a/packages/playground/website/src/components/layout/index.tsx +++ b/packages/playground/website/src/components/layout/index.tsx @@ -33,6 +33,7 @@ import { ImportFormModal } from '../import-form-modal'; import { PreviewPRModal } from '../../github/preview-pr'; import { MissingSiteModal } from '../missing-site-modal'; import { RenameSiteModal } from '../rename-site-modal'; +import { SaveSiteToLocalModal } from '../save-site-to-local-modal'; acquireOAuthTokenIfNeeded(); @@ -47,6 +48,7 @@ export const modalSlugs = { PREVIEW_PR_GUTENBERG: 'preview-pr-gutenberg', MISSING_SITE_PROMPT: 'missing-site-prompt', RENAME_SITE: 'rename-site', + SAVE_SITE_TO_LOCAL_DIRECTORY: 'save-site-to-local-directory', }; const displayMode = getDisplayModeFromQuery(); @@ -228,6 +230,8 @@ function Modals(blueprint: BlueprintV1Declaration) { return ; } else if (currentModal === modalSlugs.RENAME_SITE) { return ; + } else if (currentModal === modalSlugs.SAVE_SITE_TO_LOCAL_DIRECTORY) { + return ; } if (query.get('gh-ensure-auth') === 'yes') { diff --git a/packages/playground/website/src/components/rename-site-modal/index.tsx b/packages/playground/website/src/components/rename-site-modal/index.tsx index ed6784a533..0f490751b3 100644 --- a/packages/playground/website/src/components/rename-site-modal/index.tsx +++ b/packages/playground/website/src/components/rename-site-modal/index.tsx @@ -18,7 +18,7 @@ export function RenameSiteModal() { const [name, setName] = useState(initialName); const [isSubmitting, setIsSubmitting] = useState(false); - if (!site) { + if (!site || site.metadata.storage === 'none') { // Nothing to rename return null; } @@ -63,6 +63,7 @@ export function RenameSiteModal() { label="Name" value={name} onChange={(val: string) => setName(val)} + placeholder="e.g. Testing Gutenberg 24.17" autoFocus /> + state.ui.activeSite?.slug + ? state.sites.entities[state.ui.activeSite.slug] + : undefined + ); + + const initialName = useMemo(() => site?.metadata?.name ?? '', [site]); + const [name, setName] = useState(initialName); + const [directoryHandle, setDirectoryHandle] = + useState(null); + const [directoryError, setDirectoryError] = useState(null); + const [isSubmitting, setIsSubmitting] = useState(false); + + if (!site || site.metadata.storage !== 'none') { + return null; + } + + const closeModal = () => dispatch(setActiveModal(null)); + + const handlePickDirectory = async () => { + if (!(window as any).showDirectoryPicker) { + setDirectoryError( + 'Directory selection is not supported in this browser.' + ); + return; + } + try { + const handle: FileSystemDirectoryHandle = await ( + window as any + ).showDirectoryPicker({ + id: 'playground-directory', + mode: 'readwrite', + }); + setDirectoryHandle(handle); + setDirectoryError(null); + } catch (error: any) { + if (error?.name === 'AbortError') { + return; + } + setDirectoryError('Unable to access the selected directory.'); + } + }; + + const handleSubmit = async () => { + const trimmedName = name.trim(); + if (!trimmedName || !directoryHandle) { + if (!directoryHandle) { + setDirectoryError('Choose a directory to continue.'); + } + return; + } + + try { + setIsSubmitting(true); + await dispatch( + persistTemporarySite(site.slug, 'local-fs', { + siteName: trimmedName, + localFsHandle: directoryHandle, + skipRenameModal: true, + }) as any + ); + closeModal(); + } catch (error) { + setDirectoryError('Saving failed. Please try again.'); + } finally { + setIsSubmitting(false); + } + }; + + return ( + +
{ + event.preventDefault(); + handleSubmit(); + }} + style={{ display: 'flex', flexDirection: 'column', gap: 12 }} + > + setName(value)} + autoFocus + /> + +
+ + +
+ {directoryError ? ( +

+ {directoryError} +

+ ) : null} +
+ + +
+ ); +} diff --git a/packages/playground/website/src/components/site-manager/site-info-panel/index.tsx b/packages/playground/website/src/components/site-manager/site-info-panel/index.tsx index 12af8c9cde..a56a58f0ba 100644 --- a/packages/playground/website/src/components/site-manager/site-info-panel/index.tsx +++ b/packages/playground/website/src/components/site-manager/site-info-panel/index.tsx @@ -11,7 +11,7 @@ import { MenuItem, TabPanel, } from '@wordpress/components'; -import { moreVertical, external, chevronLeft } from '@wordpress/icons'; +import { moreVertical, external, chevronLeft, edit } from '@wordpress/icons'; import { SiteLogs } from '../../log-modal'; import { useAppDispatch, useAppSelector } from '../../../lib/state/redux/store'; import { usePlaygroundClientInfo } from '../../../lib/use-playground-client'; @@ -136,14 +136,39 @@ export function SiteInfoPanel({ -

- {isTemporary - ? 'Temporary Playground' - : site.metadata.name} -

+

+ {isTemporary + ? 'Temporary Playground' + : site.metadata.name} +

+ {!isTemporary && ( + - - {directoryError ? ( -

- {directoryError} -

- ) : null} - - - - - ); -} diff --git a/packages/playground/website/src/components/site-manager/site-persist-button/index.tsx b/packages/playground/website/src/components/site-manager/site-persist-button/index.tsx index 88ee7bd0cd..a306631794 100644 --- a/packages/playground/website/src/components/site-manager/site-persist-button/index.tsx +++ b/packages/playground/website/src/components/site-manager/site-persist-button/index.tsx @@ -1,16 +1,6 @@ import { useAppSelector, useAppDispatch } from '../../../lib/state/redux/store'; -import { - DropdownMenu, - DropdownMenuItem, - DropdownMenuItemLabel, - DropdownMenuItemHelpText, - // @ts-ignore -} from '@wordpress/components/build/dropdown-menu-v2/index.js'; import css from './style.module.css'; -import { persistTemporarySite } from '../../../lib/state/redux/persist-temporary-site'; import { selectClientInfoBySiteSlug } from '../../../lib/state/redux/slice-clients'; -import { useLocalFsAvailability } from '../../../lib/hooks/use-local-fs-availability'; -import { isOpfsAvailable } from '../../../lib/state/opfs/opfs-site-storage'; import type { SiteStorageType } from '../../../lib/state/redux/slice-sites'; import { setActiveModal } from '../../../lib/state/redux/slice-ui'; import { modalSlugs } from '../../layout'; @@ -18,7 +8,6 @@ import { modalSlugs } from '../../layout'; export function SitePersistButton({ siteSlug, children, - storage = null, }: { siteSlug: string; children: React.ReactNode; @@ -27,72 +16,13 @@ export function SitePersistButton({ const clientInfo = useAppSelector((state) => selectClientInfoBySiteSlug(state, siteSlug) ); - const localFsAvailability = useLocalFsAvailability(clientInfo?.client); const dispatch = useAppDispatch(); if (!clientInfo?.opfsSync || clientInfo.opfsSync?.status === 'error') { - let button = null; - if (storage) { - const handleClick = () => { - if (storage === 'local-fs') { - dispatch( - setActiveModal(modalSlugs.SAVE_SITE_TO_LOCAL_DIRECTORY) - ); - return; - } - if (storage === 'opfs') { - dispatch(setActiveModal(modalSlugs.SAVE_SITE_TO_BROWSER)); - return; - } - dispatch(persistTemporarySite(siteSlug, storage)); - }; - button =
{children}
; - } else { - button = ( - - - dispatch( - setActiveModal(modalSlugs.SAVE_SITE_TO_BROWSER) - ) - } - > - - Save in this browser - - {!isOpfsAvailable && ( - - {localFsAvailability === 'not-available' - ? 'Not available in this browser' - : 'Not available on this site'} - - )} - - - dispatch( - setActiveModal( - modalSlugs.SAVE_SITE_TO_LOCAL_DIRECTORY - ) - ) - } - > - - Save in a local directory - - {localFsAvailability !== 'available' && ( - - {localFsAvailability === 'not-available' - ? 'Not available in this browser' - : 'Not available on this site'} - - )} - - - ); - } + const handleClick = () => { + dispatch(setActiveModal(modalSlugs.SAVE_SITE)); + }; + const button =
{children}
; return ( <> diff --git a/packages/playground/website/src/lib/state/redux/slice-ui.ts b/packages/playground/website/src/lib/state/redux/slice-ui.ts index 672a3fe7ab..0e4e23419a 100644 --- a/packages/playground/website/src/lib/state/redux/slice-ui.ts +++ b/packages/playground/website/src/lib/state/redux/slice-ui.ts @@ -31,12 +31,13 @@ const shouldOpenSiteManagerByDefault = false; const initialState: UIState = { /** - * Don't show the error report modal after a page refresh. - * There's an action call below to remove the error-report modal attribute - * from the URL. + * Don't show certain modals after a page refresh. + * The save-site and error-report modals should only be triggered by user actions, + * not by loading a URL with the modal parameter. */ activeModal: - query.get('modal') === 'error-report' + query.get('modal') === 'error-report' || + query.get('modal') === 'save-site' ? null : query.get('modal') || null, offline: !navigator.onLine, @@ -115,11 +116,14 @@ export const listenToOnlineOfflineEventsMiddleware: Middleware = }); } /** - * Hide the error report modal on page load. - * It's too common to refresh the page after an error occurs, - * let's not bother the user with an empty error reporting modal. + * Hide certain modals on page load and remove them from the URL. + * These modals should only be triggered by user actions, not by + * loading a URL with the modal parameter. */ - if (query.get('modal') === 'error-report') { + if ( + query.get('modal') === 'error-report' || + query.get('modal') === 'save-site' + ) { setTimeout(() => { store.dispatch(uiSlice.actions.setActiveModal(null)); }, 0); From 8936a6522c0de4a703937b0023dae72641d4dbaa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Thu, 16 Oct 2025 12:48:36 +0200 Subject: [PATCH 04/11] Add the missing site save modal --- .../src/components/save-site-modal/index.tsx | 366 ++++++++++++++++++ 1 file changed, 366 insertions(+) create mode 100644 packages/playground/website/src/components/save-site-modal/index.tsx diff --git a/packages/playground/website/src/components/save-site-modal/index.tsx b/packages/playground/website/src/components/save-site-modal/index.tsx new file mode 100644 index 0000000000..3fa1585785 --- /dev/null +++ b/packages/playground/website/src/components/save-site-modal/index.tsx @@ -0,0 +1,366 @@ +import { + useEffect, + useMemo, + useState, + useRef, + type CSSProperties, +} from 'react'; +import { + Button, + BaseControl, + TextControl, + RadioControl, +} from '@wordpress/components'; +import { Modal } from '../modal'; +import ModalButtons from '../modal/modal-buttons'; +import { useAppDispatch, useAppSelector } from '../../lib/state/redux/store'; +import { setActiveModal } from '../../lib/state/redux/slice-ui'; +import { useLocalFsAvailability } from '../../lib/hooks/use-local-fs-availability'; +import { selectClientInfoBySiteSlug } from '../../lib/state/redux/slice-clients'; +import { persistTemporarySite } from '../../lib/state/redux/persist-temporary-site'; +import type { SiteStorageType } from '../../lib/state/redux/slice-sites'; +import { logger } from '@php-wasm/logger'; +import { isOpfsAvailable } from '../../lib/state/opfs/opfs-site-storage'; + +type StorageOption = Extract; + +const helpTextStyle: CSSProperties = { + color: '#757575', + fontSize: 12, + marginTop: 8, +}; + +const errorTextStyle: CSSProperties = { + color: '#d63638', + marginTop: 8, +}; + +export function SaveSiteModal() { + const dispatch = useAppDispatch(); + const site = useAppSelector((state) => + state.ui.activeSite?.slug + ? state.sites.entities[state.ui.activeSite.slug] + : undefined + ); + const clientInfo = useAppSelector((state) => + state.ui.activeSite?.slug + ? selectClientInfoBySiteSlug(state, state.ui.activeSite.slug) + : undefined + ); + + const localFsAvailability = useLocalFsAvailability(clientInfo?.client); + + const initialName = useMemo(() => site?.metadata?.name ?? '', [site]); + const [name, setName] = useState(initialName); + const [selectedStorage, setSelectedStorage] = useState( + () => { + if (isOpfsAvailable) { + return 'opfs'; + } + if (localFsAvailability === 'available') { + return 'local-fs'; + } + return 'opfs'; + } + ); + const [directoryHandle, setDirectoryHandle] = + useState(null); + const [directoryPermission, setDirectoryPermission] = + useState(null); + const [directoryError, setDirectoryError] = useState(null); + const [submitError, setSubmitError] = useState(null); + const [isSubmitting, setIsSubmitting] = useState(false); + const nameInputRef = useRef(null); + + useEffect(() => { + setName(initialName); + }, [initialName]); + + useEffect(() => { + // Select the text in the name input when the modal is shown + // Use a small delay to ensure the input is focused first by autoFocus + const timer = setTimeout(() => { + // Try using the ref first + if (nameInputRef.current) { + nameInputRef.current.select(); + } else if (document.activeElement instanceof HTMLInputElement) { + // Fallback: if autoFocus worked, the active element should be our input + document.activeElement.select(); + } + }, 0); + return () => clearTimeout(timer); + }, []); + + useEffect(() => { + if ( + selectedStorage === 'local-fs' && + localFsAvailability !== 'available' + ) { + setSelectedStorage('opfs'); + } + }, [selectedStorage, localFsAvailability]); + + useEffect(() => { + if ( + selectedStorage === 'opfs' && + !isOpfsAvailable && + localFsAvailability === 'available' + ) { + setSelectedStorage('local-fs'); + } + }, [selectedStorage, localFsAvailability]); + + useEffect(() => { + setDirectoryHandle(null); + setDirectoryPermission(null); + setDirectoryError(null); + }, [site?.slug]); + + if (!site || site.metadata.storage !== 'none') { + return null; + } + + const closeModal = () => { + dispatch(setActiveModal(null)); + }; + + const localIsAvailable = localFsAvailability === 'available'; + const localUnavailableMessage = + localFsAvailability === 'not-available' + ? 'Not available in this browser' + : 'Not available on this site'; + + const chooseStorage = (storage: StorageOption) => { + if (storage === 'local-fs' && !localIsAvailable) { + return; + } + if (storage === 'opfs' && !isOpfsAvailable) { + return; + } + setSelectedStorage(storage); + setSubmitError(null); + if (storage !== 'local-fs') { + setDirectoryError(null); + } + }; + + const requestWriteAccess = async ( + handle: FileSystemDirectoryHandle + ): Promise => { + if (typeof handle.requestPermission === 'function') { + const result = await handle.requestPermission({ + mode: 'readwrite', + }); + return (result ?? 'prompt') as PermissionState; + } + if (typeof handle.queryPermission === 'function') { + const result = await handle.queryPermission({ mode: 'readwrite' }); + return (result ?? 'prompt') as PermissionState; + } + return 'granted'; + }; + + const ensureWriteAccess = async ( + handle: FileSystemDirectoryHandle + ): Promise => { + if (typeof handle.queryPermission === 'function') { + const current = await handle.queryPermission({ + mode: 'readwrite', + }); + if (current === 'granted' || current === 'denied') { + return current; + } + } + return requestWriteAccess(handle); + }; + + const handlePickDirectory = async () => { + setSubmitError(null); + if (!(window as any).showDirectoryPicker) { + setDirectoryError( + 'Directory selection is not supported in this browser.' + ); + return; + } + try { + const handle: FileSystemDirectoryHandle = await ( + window as any + ).showDirectoryPicker({ + id: 'playground-directory', + mode: 'readwrite', + }); + const permission = await requestWriteAccess(handle); + setDirectoryHandle(handle); + setDirectoryPermission(permission); + if (permission !== 'granted') { + setDirectoryError( + 'Allow Playground to edit that directory in the browser prompt to continue.' + ); + } else { + setDirectoryError(null); + } + } catch (error: any) { + if (error?.name === 'AbortError') { + return; + } + logger.error(error); + setDirectoryError('Unable to access the selected directory.'); + } + }; + + const handleSubmit = async () => { + const trimmedName = name.trim(); + if (!trimmedName) { + return; + } + + try { + setIsSubmitting(true); + setSubmitError(null); + + if (selectedStorage === 'local-fs') { + if (!directoryHandle) { + setDirectoryError('Choose a directory to continue.'); + return; + } + const permission = await ensureWriteAccess(directoryHandle); + setDirectoryPermission(permission); + if (permission !== 'granted') { + setDirectoryError( + 'Allow Playground to edit that directory in the browser prompt to continue.' + ); + return; + } + await dispatch( + persistTemporarySite(site.slug, 'local-fs', { + siteName: trimmedName, + localFsHandle: directoryHandle, + skipRenameModal: true, + }) as any + ); + } else { + await dispatch( + persistTemporarySite(site.slug, 'opfs', { + siteName: trimmedName, + skipRenameModal: true, + }) as any + ); + } + + closeModal(); + } catch (error) { + logger.error(error); + setSubmitError('Saving failed. Please try again.'); + } finally { + setIsSubmitting(false); + } + }; + + const trimmedName = name.trim(); + const selectionIsAvailable = + (selectedStorage === 'opfs' && isOpfsAvailable) || + (selectedStorage === 'local-fs' && localIsAvailable); + const hasDirectoryAccess = + selectedStorage === 'local-fs' + ? !!directoryHandle && directoryPermission === 'granted' + : true; + const saveDisabled = + !trimmedName || + !selectionIsAvailable || + !hasDirectoryAccess || + isSubmitting; + + return ( + +
{ + event.preventDefault(); + handleSubmit(); + }} + style={{ display: 'flex', flexDirection: 'column', gap: 16 }} + autoComplete="off" + > + setName(value)} + autoFocus + ref={nameInputRef} + data-1p-ignore="true" + data-lpignore="true" + data-bwignore="true" + /> + chooseStorage(value as StorageOption)} + /> + {!isOpfsAvailable && selectedStorage === 'opfs' && ( +

Not available in this browser

+ )} + {!localIsAvailable && selectedStorage === 'local-fs' && ( +

{localUnavailableMessage}

+ )} + {selectedStorage === 'local-fs' && ( + +
+ + +
+ {directoryError ? ( +

{directoryError}

+ ) : null} +
+ )} + + {submitError ? ( +

{submitError}

+ ) : null} + +
+ ); +} From 4ae917645f978dd3f0757fafb1d492844b688794 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Thu, 16 Oct 2025 13:17:41 +0200 Subject: [PATCH 05/11] E2E tests --- .../website/playwright/e2e/website-ui.spec.ts | 273 +++++++++++++++++- 1 file changed, 257 insertions(+), 16 deletions(-) diff --git a/packages/playground/website/playwright/e2e/website-ui.spec.ts b/packages/playground/website/playwright/e2e/website-ui.spec.ts index aed79020c5..bee3f98225 100644 --- a/packages/playground/website/playwright/e2e/website-ui.spec.ts +++ b/packages/playground/website/playwright/e2e/website-ui.spec.ts @@ -1,5 +1,6 @@ import { test, expect } from '../playground-fixtures.ts'; import type { Blueprint } from '@wp-playground/blueprints'; +import type { Page } from '@playwright/test'; // We can't import the SupportedPHPVersions versions directly from the remote package // because of ESModules vs CommonJS incompatibilities. Let's just import the @@ -9,6 +10,49 @@ import { SupportedPHPVersions } from '../../../../php-wasm/universal/src/lib/sup // eslint-disable-next-line @nx/enforce-module-boundaries import * as MinifiedWordPressVersions from '../../../wordpress-builds/src/wordpress/wp-versions.json'; +/** + * Helper function to handle the save site modal flow + */ +async function saveSiteViaModal( + page: Page, + options?: { + customName?: string; + storageType?: 'opfs' | 'local-fs'; + } +) { + const { customName, storageType = 'opfs' } = options || {}; + + // Click the Save button to open the modal + await expect(page.getByText('Save')).toBeEnabled(); + await page.getByText('Save').click(); + + // Wait for the Save Playground dialog to appear + const dialog = page.getByRole('dialog', { name: 'Save Playground' }); + await expect(dialog).toBeVisible(); + + // If a custom name is provided, update it + if (customName) { + const nameInput = dialog.getByLabel('Playground name'); + await nameInput.fill(''); + await nameInput.type(customName); + } + + // Select storage location + if (storageType === 'opfs') { + await dialog.getByText('Save in this browser').click({ force: true }); + } else { + await dialog + .getByText('Save to a local directory') + .click({ force: true }); + } + + // Click the Save button in the modal + await dialog.getByRole('button', { name: 'Save' }).click(); + + // Wait for the dialog to close + await expect(dialog).not.toBeVisible({ timeout: 5000 }); +} + test('should reflect the URL update from the navigation bar in the WordPress site', async ({ website, }) => { @@ -44,12 +88,9 @@ test('should switch between sites', async ({ website, browserName }) => { await website.ensureSiteManagerIsOpen(); - await expect(website.page.getByText('Save')).toBeEnabled(); - await website.page.getByText('Save').click(); - // We shouldn't need to explicitly call .waitFor(), but the test fails without it. - // Playwright logs that something "intercepts pointer events", that's probably related. - await website.page.getByText('Save in this browser').waitFor(); - await website.page.getByText('Save in this browser').click({ force: true }); + // Save the temporary site using the modal + await saveSiteViaModal(website.page); + await expect( website.page.locator('[aria-current="page"]') ).not.toContainText('Temporary Playground', { @@ -99,12 +140,9 @@ test('should preserve PHP constants when saving a temporary site to OPFS', async await website.ensureSiteManagerIsOpen(); - await expect(website.page.getByText('Save')).toBeEnabled(); - await website.page.getByText('Save').click(); - // We shouldn't need to explicitly call .waitFor(), but the test fails without it. - // Playwright logs that something "intercepts pointer events", that's probably related. - await website.page.getByText('Save in this browser').waitFor(); - await website.page.getByText('Save in this browser').click({ force: true }); + // Save the temporary site using the modal + await saveSiteViaModal(website.page); + await expect( website.page.locator('[aria-current="page"]') ).not.toContainText('Temporary Playground', { @@ -145,10 +183,8 @@ test('should rename a saved Playground and persist after reload', async ({ await website.ensureSiteManagerIsOpen(); // Save the temporary site to OPFS so rename is available - await expect(website.page.getByText('Save')).toBeEnabled(); - await website.page.getByText('Save').click(); - await website.page.getByText('Save in this browser').waitFor(); - await website.page.getByText('Save in this browser').click({ force: true }); + await saveSiteViaModal(website.page); + await expect(website.page.getByLabel('Playground title')).not.toContainText( 'Temporary Playground', { @@ -189,6 +225,211 @@ test('should rename a saved Playground and persist after reload', async ({ ).toContainText(newName); }); +test('should show save site modal with correct elements', async ({ + website, + browserName, +}) => { + test.skip( + browserName === 'webkit', + `This test relies on OPFS which isn't available in Playwright's flavor of Safari.` + ); + + await website.goto('./'); + await website.ensureSiteManagerIsOpen(); + + // Click the Save button + await expect(website.page.getByText('Save')).toBeEnabled(); + await website.page.getByText('Save').click(); + + // Verify the modal appears with correct title + const dialog = website.page.getByRole('dialog', { + name: 'Save Playground', + }); + await expect(dialog).toBeVisible(); + + // Verify the playground name input exists and has default value + const nameInput = dialog.getByLabel('Playground name'); + await expect(nameInput).toBeVisible(); + await expect(nameInput).toHaveValue('Temporary Playground'); + + // Verify storage location radio buttons exist + await expect(dialog.getByText('Storage location')).toBeVisible(); + await expect(dialog.getByText('Save in this browser')).toBeVisible(); + await expect(dialog.getByText('Save to a local directory')).toBeVisible(); + + // Verify action buttons exist + await expect(dialog.getByRole('button', { name: 'Save' })).toBeVisible(); + await expect(dialog.getByRole('button', { name: 'Cancel' })).toBeVisible(); + + // Close the modal + await dialog.getByRole('button', { name: 'Cancel' }).click(); + await expect(dialog).not.toBeVisible(); +}); + +test('should close save site modal without saving', async ({ + website, + browserName, +}) => { + test.skip( + browserName === 'webkit', + `This test relies on OPFS which isn't available in Playwright's flavor of Safari.` + ); + + await website.goto('./'); + await website.ensureSiteManagerIsOpen(); + + // Open the modal + await website.page.getByText('Save').click(); + const dialog = website.page.getByRole('dialog', { + name: 'Save Playground', + }); + await expect(dialog).toBeVisible(); + + // Close without saving using Cancel button + await dialog.getByRole('button', { name: 'Cancel' }).click(); + await expect(dialog).not.toBeVisible(); + + // Verify the site is still temporary + await expect(website.page.getByLabel('Playground title')).toContainText( + 'Temporary Playground' + ); + + // Open the modal again + await website.page.getByText('Save').click(); + await expect(dialog).toBeVisible(); + + // Close using ESC key + await website.page.keyboard.press('Escape'); + await expect(dialog).not.toBeVisible(); + + // Verify the site is still temporary + await expect(website.page.getByLabel('Playground title')).toContainText( + 'Temporary Playground' + ); +}); + +test('should have playground name input text selected by default', async ({ + website, + browserName, +}) => { + test.skip( + browserName === 'webkit', + `This test relies on OPFS which isn't available in Playwright's flavor of Safari.` + ); + + await website.goto('./'); + await website.ensureSiteManagerIsOpen(); + + // Open the modal + await website.page.getByText('Save').click(); + const dialog = website.page.getByRole('dialog', { + name: 'Save Playground', + }); + await expect(dialog).toBeVisible(); + + const nameInput = dialog.getByLabel('Playground name'); + + // Verify the input is focused and text is selected + await expect(nameInput).toBeFocused(); + + // Type without selecting - it should replace the selected text + await website.page.keyboard.type('New Name'); + await expect(nameInput).toHaveValue('New Name'); + + // Close the modal + await dialog.getByRole('button', { name: 'Cancel' }).click(); +}); + +test('should save site with custom name', async ({ website, browserName }) => { + test.skip( + browserName === 'webkit', + `This test relies on OPFS which isn't available in Playwright's flavor of Safari.` + ); + + await website.goto('./'); + await website.ensureSiteManagerIsOpen(); + + const customName = 'My Custom Playground Name'; + + // Save with custom name using the helper + await saveSiteViaModal(website.page, { customName }); + + // Verify the site was saved with the custom name + await expect(website.page.getByLabel('Playground title')).toContainText( + customName, + { + timeout: 90000, + } + ); + await expect(website.page.locator('[aria-current="page"]')).toContainText( + customName + ); +}); + +test('should not persist save site modal through page refresh', async ({ + website, + browserName, +}) => { + test.skip( + browserName === 'webkit', + `This test relies on OPFS which isn't available in Playwright's flavor of Safari.` + ); + + await website.goto('./'); + await website.ensureSiteManagerIsOpen(); + + // Open the save modal + await website.page.getByText('Save').click(); + const dialog = website.page.getByRole('dialog', { + name: 'Save Playground', + }); + await expect(dialog).toBeVisible(); + + // Get the URL with the modal parameter + const urlWithModal = website.page.url(); + expect(urlWithModal).toContain('modal=save-site'); + + // Reload the page + await website.page.reload(); + await website.ensureSiteManagerIsOpen(); + + // Verify the modal is NOT shown after reload + await expect(dialog).not.toBeVisible(); + + // Verify the modal parameter was removed from the URL + const urlAfterReload = website.page.url(); + expect(urlAfterReload).not.toContain('modal=save-site'); +}); + +test('should display OPFS storage option as selected by default', async ({ + website, + browserName, +}) => { + test.skip( + browserName === 'webkit', + `This test relies on OPFS which isn't available in Playwright's flavor of Safari.` + ); + + await website.goto('./'); + await website.ensureSiteManagerIsOpen(); + + // Open the save modal + await website.page.getByText('Save').click(); + const dialog = website.page.getByRole('dialog', { + name: 'Save Playground', + }); + await expect(dialog).toBeVisible(); + + // Verify OPFS option is selected by default + const opfsRadio = dialog.getByRole('radio', { + name: /Save in this browser/, + }); + await expect(opfsRadio).toBeChecked(); + + // Close the modal + await dialog.getByRole('button', { name: 'Cancel' }).click(); +}); + SupportedPHPVersions.forEach(async (version) => { test(`should switch PHP version to ${version}`, async ({ website }) => { await website.goto(`./`); From 736d59695701f8cb01720e6c627ebec1ef338dca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Thu, 16 Oct 2025 22:25:26 +0200 Subject: [PATCH 06/11] E2E failures --- .../website/playwright/e2e/website-ui.spec.ts | 40 ++++++++++--------- 1 file changed, 22 insertions(+), 18 deletions(-) diff --git a/packages/playground/website/playwright/e2e/website-ui.spec.ts b/packages/playground/website/playwright/e2e/website-ui.spec.ts index bee3f98225..b0c48708a4 100644 --- a/packages/playground/website/playwright/e2e/website-ui.spec.ts +++ b/packages/playground/website/playwright/e2e/website-ui.spec.ts @@ -23,12 +23,12 @@ async function saveSiteViaModal( const { customName, storageType = 'opfs' } = options || {}; // Click the Save button to open the modal - await expect(page.getByText('Save')).toBeEnabled(); - await page.getByText('Save').click(); + await expect(page.getByText('Save').first()).toBeEnabled(); + await page.getByText('Save').first().click(); // Wait for the Save Playground dialog to appear const dialog = page.getByRole('dialog', { name: 'Save Playground' }); - await expect(dialog).toBeVisible(); + await expect(dialog).toBeVisible({ timeout: 10000 }); // If a custom name is provided, update it if (customName) { @@ -37,10 +37,14 @@ async function saveSiteViaModal( await nameInput.type(customName); } - // Select storage location + // Select storage location - wait for the radio button to be available first if (storageType === 'opfs') { + // We shouldn't need to explicitly call .waitFor(), but the test fails without it. + // Playwright logs that something "intercepts pointer events", that's probably related. + await dialog.getByText('Save in this browser').waitFor(); await dialog.getByText('Save in this browser').click({ force: true }); } else { + await dialog.getByText('Save to a local directory').waitFor(); await dialog .getByText('Save to a local directory') .click({ force: true }); @@ -50,7 +54,7 @@ async function saveSiteViaModal( await dialog.getByRole('button', { name: 'Save' }).click(); // Wait for the dialog to close - await expect(dialog).not.toBeVisible({ timeout: 5000 }); + await expect(dialog).not.toBeVisible({ timeout: 10000 }); } test('should reflect the URL update from the navigation bar in the WordPress site', async ({ @@ -238,14 +242,14 @@ test('should show save site modal with correct elements', async ({ await website.ensureSiteManagerIsOpen(); // Click the Save button - await expect(website.page.getByText('Save')).toBeEnabled(); - await website.page.getByText('Save').click(); + await expect(website.page.getByText('Save').first()).toBeEnabled(); + await website.page.getByText('Save').first().click(); // Verify the modal appears with correct title const dialog = website.page.getByRole('dialog', { name: 'Save Playground', }); - await expect(dialog).toBeVisible(); + await expect(dialog).toBeVisible({ timeout: 10000 }); // Verify the playground name input exists and has default value const nameInput = dialog.getByLabel('Playground name'); @@ -279,11 +283,11 @@ test('should close save site modal without saving', async ({ await website.ensureSiteManagerIsOpen(); // Open the modal - await website.page.getByText('Save').click(); + await website.page.getByText('Save').first().click(); const dialog = website.page.getByRole('dialog', { name: 'Save Playground', }); - await expect(dialog).toBeVisible(); + await expect(dialog).toBeVisible({ timeout: 10000 }); // Close without saving using Cancel button await dialog.getByRole('button', { name: 'Cancel' }).click(); @@ -295,8 +299,8 @@ test('should close save site modal without saving', async ({ ); // Open the modal again - await website.page.getByText('Save').click(); - await expect(dialog).toBeVisible(); + await website.page.getByText('Save').first().click(); + await expect(dialog).toBeVisible({ timeout: 10000 }); // Close using ESC key await website.page.keyboard.press('Escape'); @@ -321,11 +325,11 @@ test('should have playground name input text selected by default', async ({ await website.ensureSiteManagerIsOpen(); // Open the modal - await website.page.getByText('Save').click(); + await website.page.getByText('Save').first().click(); const dialog = website.page.getByRole('dialog', { name: 'Save Playground', }); - await expect(dialog).toBeVisible(); + await expect(dialog).toBeVisible({ timeout: 10000 }); const nameInput = dialog.getByLabel('Playground name'); @@ -379,11 +383,11 @@ test('should not persist save site modal through page refresh', async ({ await website.ensureSiteManagerIsOpen(); // Open the save modal - await website.page.getByText('Save').click(); + await website.page.getByText('Save').first().click(); const dialog = website.page.getByRole('dialog', { name: 'Save Playground', }); - await expect(dialog).toBeVisible(); + await expect(dialog).toBeVisible({ timeout: 10000 }); // Get the URL with the modal parameter const urlWithModal = website.page.url(); @@ -414,11 +418,11 @@ test('should display OPFS storage option as selected by default', async ({ await website.ensureSiteManagerIsOpen(); // Open the save modal - await website.page.getByText('Save').click(); + await website.page.getByText('Save').first().click(); const dialog = website.page.getByRole('dialog', { name: 'Save Playground', }); - await expect(dialog).toBeVisible(); + await expect(dialog).toBeVisible({ timeout: 10000 }); // Verify OPFS option is selected by default const opfsRadio = dialog.getByRole('radio', { From 903dec0f8d616b2117715193862d34560ee260cd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Fri, 17 Oct 2025 00:34:06 +0200 Subject: [PATCH 07/11] E2E adjustments --- .../website/playwright/e2e/website-ui.spec.ts | 7 +-- .../website/playwright/playwright.config.ts | 9 +-- .../src/components/save-site-modal/index.tsx | 56 ++++++++++++++++--- .../site-persist-button/index.tsx | 36 ++---------- 4 files changed, 63 insertions(+), 45 deletions(-) diff --git a/packages/playground/website/playwright/e2e/website-ui.spec.ts b/packages/playground/website/playwright/e2e/website-ui.spec.ts index b0c48708a4..21dd1259b3 100644 --- a/packages/playground/website/playwright/e2e/website-ui.spec.ts +++ b/packages/playground/website/playwright/e2e/website-ui.spec.ts @@ -196,11 +196,10 @@ test('should rename a saved Playground and persist after reload', async ({ } ); - // Open actions menu and trigger Rename + // Click the pencil/edit button next to the playground name await website.page - .getByRole('button', { name: 'Additional actions' }) + .getByRole('button', { name: 'Rename Playground' }) .click(); - await website.page.getByRole('menuitem', { name: 'Rename' }).click(); const newName = 'My Renamed Playground'; const dialog = website.page.getByRole('dialog', { @@ -254,7 +253,7 @@ test('should show save site modal with correct elements', async ({ // Verify the playground name input exists and has default value const nameInput = dialog.getByLabel('Playground name'); await expect(nameInput).toBeVisible(); - await expect(nameInput).toHaveValue('Temporary Playground'); + await expect(nameInput).toHaveValue(/.+/); // Verify storage location radio buttons exist await expect(dialog.getByText('Storage location')).toBeVisible(); diff --git a/packages/playground/website/playwright/playwright.config.ts b/packages/playground/website/playwright/playwright.config.ts index 7491a56d8c..1ccbd42a5c 100644 --- a/packages/playground/website/playwright/playwright.config.ts +++ b/packages/playground/website/playwright/playwright.config.ts @@ -17,6 +17,7 @@ export const playwrightConfig: PlaywrightTestConfig = { reporter: [['html'], ['list', { printSteps: true }]], /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ use: { + headless: false, /* Base URL to use in actions like `await page.goto('/')`. */ baseURL, /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ @@ -40,10 +41,10 @@ export const playwrightConfig: PlaywrightTestConfig = { }, }, - { - name: 'firefox', - use: { ...devices['Desktop Firefox'] }, - }, + // { + // name: 'firefox', + // use: { ...devices['Desktop Firefox'] }, + // }, // Safari runner is disabled in CI – it used to be enabled but the tests // failed randomly without any obvious reason. diff --git a/packages/playground/website/src/components/save-site-modal/index.tsx b/packages/playground/website/src/components/save-site-modal/index.tsx index 3fa1585785..e1b69ba583 100644 --- a/packages/playground/website/src/components/save-site-modal/index.tsx +++ b/packages/playground/website/src/components/save-site-modal/index.tsx @@ -116,6 +116,24 @@ export function SaveSiteModal() { setDirectoryError(null); }, [site?.slug]); + // Monitor save progress through opfsSync status + const saveProgress = clientInfo?.opfsSync; + const isSaving = isSubmitting || saveProgress?.status === 'syncing'; + const savingProgress = + saveProgress?.status === 'syncing' ? saveProgress.progress : undefined; + + // Close modal when save completes successfully + useEffect(() => { + if ( + isSubmitting && + saveProgress?.status !== 'syncing' && + saveProgress?.status !== 'error' && + site?.metadata?.storage !== 'none' + ) { + dispatch(setActiveModal(null)); + } + }, [isSubmitting, saveProgress?.status, site?.metadata?.storage, dispatch]); + if (!site || site.metadata.storage !== 'none') { return null; } @@ -247,11 +265,10 @@ export function SaveSiteModal() { ); } - closeModal(); + // Don't close modal here - useEffect will close it when save completes } catch (error) { logger.error(error); setSubmitError('Saving failed. Please try again.'); - } finally { setIsSubmitting(false); } }; @@ -268,13 +285,20 @@ export function SaveSiteModal() { !trimmedName || !selectionIsAvailable || !hasDirectoryAccess || - isSubmitting; + isSaving; + + const handleRequestClose = () => { + if (!isSaving) { + closeModal(); + } + }; return (
chooseStorage(value as StorageOption)} + disabled={isSaving} /> {!isOpfsAvailable && selectedStorage === 'opfs' && (

Not available in this browser

@@ -341,6 +367,7 @@ export function SaveSiteModal() { type="button" variant="secondary" onClick={handlePickDirectory} + disabled={isSaving} > Choose... @@ -350,11 +377,26 @@ export function SaveSiteModal() { ) : null} )} + {isSaving && ( +
+ +

+ {savingProgress + ? `Saving ${savingProgress.files} / ${savingProgress.total} files...` + : 'Preparing to save...'} +

+
+ )} {submitError ? ( diff --git a/packages/playground/website/src/components/site-manager/site-persist-button/index.tsx b/packages/playground/website/src/components/site-manager/site-persist-button/index.tsx index a306631794..9e460b467f 100644 --- a/packages/playground/website/src/components/site-manager/site-persist-button/index.tsx +++ b/packages/playground/website/src/components/site-manager/site-persist-button/index.tsx @@ -4,6 +4,7 @@ import { selectClientInfoBySiteSlug } from '../../../lib/state/redux/slice-clien import type { SiteStorageType } from '../../../lib/state/redux/slice-sites'; import { setActiveModal } from '../../../lib/state/redux/slice-ui'; import { modalSlugs } from '../../layout'; +import React from 'react'; export function SitePersistButton({ siteSlug, @@ -36,34 +37,9 @@ export function SitePersistButton({ ); } - if ( - clientInfo?.opfsSync?.status === 'syncing' && - !clientInfo?.opfsSync?.progress - ) { - return ( -
-
- -
-
Preparing to save...
-
- ); - } - - return ( -
-
- -
-
- {clientInfo.opfsSync.progress?.files} - {' / '} - {clientInfo.opfsSync.progress?.total} files saved -
-
- ); + return React.cloneElement(children as React.ReactElement, { + className: css.inProgress, + disabled: true, + children: 'Saving...', + }); } From b3d8bec0ad83bf42cb3891e516a539b27d8c5e79 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Fri, 17 Oct 2025 01:01:34 +0200 Subject: [PATCH 08/11] restore Playwright config --- .../playground/website/playwright/playwright.config.ts | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/packages/playground/website/playwright/playwright.config.ts b/packages/playground/website/playwright/playwright.config.ts index 1ccbd42a5c..7491a56d8c 100644 --- a/packages/playground/website/playwright/playwright.config.ts +++ b/packages/playground/website/playwright/playwright.config.ts @@ -17,7 +17,6 @@ export const playwrightConfig: PlaywrightTestConfig = { reporter: [['html'], ['list', { printSteps: true }]], /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ use: { - headless: false, /* Base URL to use in actions like `await page.goto('/')`. */ baseURL, /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ @@ -41,10 +40,10 @@ export const playwrightConfig: PlaywrightTestConfig = { }, }, - // { - // name: 'firefox', - // use: { ...devices['Desktop Firefox'] }, - // }, + { + name: 'firefox', + use: { ...devices['Desktop Firefox'] }, + }, // Safari runner is disabled in CI – it used to be enabled but the tests // failed randomly without any obvious reason. From ee5311f247fee8c15e1907661f5f81931c9f9a6b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Tue, 21 Oct 2025 15:23:13 +0200 Subject: [PATCH 09/11] Wrap long Playground titles --- .../site-manager/site-info-panel/index.tsx | 82 ++++++++++++------- .../site-info-panel/style.module.css | 63 +++++++++++--- packages/playground/website/src/styles.css | 4 + 3 files changed, 106 insertions(+), 43 deletions(-) diff --git a/packages/playground/website/src/components/site-manager/site-info-panel/index.tsx b/packages/playground/website/src/components/site-manager/site-info-panel/index.tsx index 690caf50ca..8dd503c77b 100644 --- a/packages/playground/website/src/components/site-manager/site-info-panel/index.tsx +++ b/packages/playground/website/src/components/site-manager/site-info-panel/index.tsx @@ -82,6 +82,11 @@ export function SiteInfoPanel({ ? (opfsMountDescriptor as any)?.device?.handle?.name : undefined; + const title = isTemporary ? 'Temporary Playground' : site.metadata.name; + const titleWords = title.split(' '); + const titleStart = titleWords.slice(0, -1).join(' '); + const titleEnd = titleWords[titleWords.length - 1]; + return (
-

- {isTemporary - ? 'Temporary Playground' - : site.metadata.name} - {!isTemporary && ( -

+ > + {titleStart}{' '} + + {titleEnd} + {!isTemporary && ( +