From 796f66e0860dbcb93d60a205cd307abf47f6ef71 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Sun, 17 May 2026 21:06:36 +0200 Subject: [PATCH 01/47] Save Playgrounds by default --- .../developers/06-apis/query-api/01-index.md | 3 +- .../website/playwright/e2e/website-ui.spec.ts | 89 ++++- .../components/blueprint-url-modal/index.tsx | 2 +- .../ensure-playground-site-is-selected.tsx | 53 ++- .../src/components/save-site-modal/index.tsx | 3 +- .../saved-playgrounds-overlay/index.tsx | 334 +++++++++--------- .../style.module.css | 26 ++ .../site-manager/blueprints-panel/index.tsx | 2 +- .../redux/site-management-api-middleware.ts | 66 ++++ .../website/src/lib/state/redux/site-slug.ts | 29 ++ .../src/lib/state/redux/slice-sites.spec.ts | 23 ++ .../src/lib/state/redux/slice-sites.ts | 99 +++++- .../website/src/lib/state/url/router.spec.ts | 47 ++- .../website/src/lib/state/url/router.ts | 35 +- 14 files changed, 616 insertions(+), 195 deletions(-) create mode 100644 packages/playground/website/src/lib/state/redux/site-slug.ts create mode 100644 packages/playground/website/src/lib/state/redux/slice-sites.spec.ts diff --git a/packages/docs/site/docs/developers/06-apis/query-api/01-index.md b/packages/docs/site/docs/developers/06-apis/query-api/01-index.md index 83af47ae45c..a0c11373dea 100644 --- a/packages/docs/site/docs/developers/06-apis/query-api/01-index.md +++ b/packages/docs/site/docs/developers/06-apis/query-api/01-index.md @@ -38,7 +38,8 @@ You can go ahead and try it out. The Playground will automatically install the t | `multisite` | `no` | Enables the WordPress multisite mode. Accepts `yes` or `no`. | | `import-site` | | Imports site files and database from a ZIP file specified by a URL. | | `import-wxr` | | Imports site content from a WXR file specified by a URL. It uses the WordPress Importer plugin, so the default admin user must be logged in. | -| `site-slug` | | Selects which site to load from browser storage. If the specified site does not exist, the user will be prompted to save a new site with the specified slug. | +| `site-slug` | | Selects which site to load from browser storage. If the specified site does not exist, Playground creates a new browser-saved site with the specified slug unless temporary storage is requested. | +| `storage` | | Controls whether the Playground is saved by default. Use `storage=temp` to create an unsaved temporary Playground that is reset when the page is refreshed or closed. | | `language` | `en_US` | Sets the locale for the WordPress instance. This must be used in combination with `networking=yes` otherwise WordPress won't be able to download translations. | | `core-pr` | | Installs a specific https://github.com/WordPress/wordpress-develop core PR. Accepts the PR number. For example, `core-pr=6883`. | | `gutenberg-pr` | | Installs a specific https://github.com/WordPress/gutenberg PR. Accepts the PR number. For example, `gutenberg-pr=65337`. | diff --git a/packages/playground/website/playwright/e2e/website-ui.spec.ts b/packages/playground/website/playwright/e2e/website-ui.spec.ts index df4c3df7aa8..039d83d1064 100644 --- a/packages/playground/website/playwright/e2e/website-ui.spec.ts +++ b/packages/playground/website/playwright/e2e/website-ui.spec.ts @@ -583,23 +583,102 @@ test.describe('Database panel', () => { }); }); -// Test saving playgrounds by default and when the "can-save" URL parameter is set to "no". -test.describe('Save Status Indicator', () => { - test('should show "Unsaved Playground" status for temporary playgrounds', async ({ +// Test browser-saved Playgrounds by default and explicit temporary opt-outs. +test.describe('Default Playground storage', () => { + test('should create a browser-saved Playground by default', async ({ website, }) => { await website.goto('./'); await website.ensureSiteManagerIsClosed(); + await expect + .poll(() => + new URL(website.page.url()).searchParams.get('site-slug') + ) + .toBeTruthy(); + await expect(website.page.getByText('Unsaved Playground')).toHaveCount( + 0 + ); + }); + + test('should show browser storage details in the Site Manager by default', async ({ + website, + }) => { + await website.goto('./'); + await website.ensureSiteManagerIsOpen(); + + await expect( + website.page.getByText('Saved in this browser') + ).toBeVisible(); + await expect( + website.page.getByText( + 'This is an Unsaved Playground. Your changes will be lost on page refresh.' + ) + ).toHaveCount(0); + }); + + test('should persist WordPress changes after refreshing the default Playground', async ({ + website, + }) => { + await website.goto('./'); + await expect + .poll(() => + new URL(website.page.url()).searchParams.get('site-slug') + ) + .toBeTruthy(); + const siteSlug = new URL(website.page.url()).searchParams.get( + 'site-slug' + ); + expect(siteSlug).toBeTruthy(); + + const expectedBlogName = `Saved Playground ${Date.now()}`; + await website.page.evaluate(async (blogName) => { + const playground = (window as any).playground; + await playground.run({ + code: ` { + const playground = (window as any).playground; + const result = await playground.run({ + code: ` { + await website.goto('./?storage=temp'); + await website.ensureSiteManagerIsClosed(); + const indicator = website.page.getByText('Unsaved Playground'); await expect(indicator).toBeVisible(); await expect(indicator).toHaveCount(1); + expect(new URL(website.page.url()).searchParams.get('storage')).toBe( + 'temp' + ); }); - test('should see save playground message in the Site Manager', async ({ + test('should see save playground message in the Site Manager for storage=temp Playgrounds', async ({ website, }) => { - await website.goto('./'); + await website.goto('./?storage=temp'); await website.ensureSiteManagerIsOpen(); const indicator = website.page.getByText( diff --git a/packages/playground/website/src/components/blueprint-url-modal/index.tsx b/packages/playground/website/src/components/blueprint-url-modal/index.tsx index cbdb41565b2..536c4c1255c 100644 --- a/packages/playground/website/src/components/blueprint-url-modal/index.tsx +++ b/packages/playground/website/src/components/blueprint-url-modal/index.tsx @@ -23,7 +23,7 @@ export function BlueprintUrlModal() { dispatch(setSiteManagerOpen(false)); closeModal(); redirectTo( - PlaygroundRoute.newTemporarySite({ + PlaygroundRoute.newSite({ query: { 'blueprint-url': trimmed, }, diff --git a/packages/playground/website/src/components/ensure-playground-site/ensure-playground-site-is-selected.tsx b/packages/playground/website/src/components/ensure-playground-site/ensure-playground-site-is-selected.tsx index f942ca1ba59..9e0e8eee62b 100644 --- a/packages/playground/website/src/components/ensure-playground-site/ensure-playground-site-is-selected.tsx +++ b/packages/playground/website/src/components/ensure-playground-site/ensure-playground-site-is-selected.tsx @@ -1,5 +1,9 @@ import { useEffect, useState } from 'react'; import { useCurrentUrl } from '../../lib/state/url/router-hooks'; +import { + isSaveDisabledByQueryParam, + isTemporaryStorageRequested, +} from '../../lib/state/url/router'; import { opfsSiteStorage } from '../../lib/state/opfs/opfs-site-storage'; import { OPFSSitesLoaded, @@ -40,6 +44,10 @@ export function EnsurePlaygroundSiteIsSelected({ const requestedSiteObject = useAppSelector((state) => selectSiteBySlug(state, requestedSiteSlug!) ); + const shouldUseTemporarySite = + isTemporaryStorageRequested(url.href) || + isSaveDisabledByQueryParam() || + !opfsSiteStorage; const requestedClientInfo = useAppSelector( (state) => requestedSiteSlug && @@ -78,14 +86,37 @@ export function EnsurePlaygroundSiteIsSelected({ // If the site slug is provided, try to load the site. if (requestedSiteSlug) { - // If the site does not exist, create a new temporary site and prompt the user to save it. + // If the site does not exist, create it. Saved browser + // storage is the default unless the URL explicitly asks for + // a temporary site or saving is unavailable. if (!requestedSiteObject) { logger.log( - 'The requested site was not found. Creating a new temporary site.' + 'The requested site was not found. Creating a new site.' ); - await sitesAPI.createNewTemporarySite(requestedSiteSlug); - setNeedMissingSitePromptForSlug(requestedSiteSlug); + if (shouldUseTemporarySite) { + await sitesAPI.createNewTemporarySite( + requestedSiteSlug + ); + if (!isSaveDisabledByQueryParam()) { + setNeedMissingSitePromptForSlug(requestedSiteSlug); + } + } else { + try { + await sitesAPI.createNewSavedSite( + requestedSiteSlug + ); + } catch (error) { + logger.error( + 'Error creating saved site. Falling back to a temporary site.', + error + ); + await sitesAPI.createNewTemporarySite( + requestedSiteSlug + ); + setNeedMissingSitePromptForSlug(requestedSiteSlug); + } + } return; } @@ -105,7 +136,19 @@ export function EnsurePlaygroundSiteIsSelected({ return; } - await sitesAPI.createNewTemporarySite(); + if (shouldUseTemporarySite) { + await sitesAPI.createNewTemporarySite(); + } else { + try { + await sitesAPI.createNewSavedSite(); + } catch (error) { + logger.error( + 'Error creating saved site. Falling back to a temporary site.', + error + ); + await sitesAPI.createNewTemporarySite(); + } + } } ensureSiteIsSelected(); 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 12991816877..675b3ffe610 100644 --- a/packages/playground/website/src/components/save-site-modal/index.tsx +++ b/packages/playground/website/src/components/save-site-modal/index.tsx @@ -304,7 +304,8 @@ export function SaveSiteModal() { >

This Playground is temporary and will be lost when you - refresh or close this page. Save it to keep your work. + refresh or close this page. Save it to keep your work and + find it later in Your Playgrounds.

(null); + const yourPlaygroundsRef = useRef(null); const [viewMode, setViewMode] = useState(initialViewMode); const [searchQuery, setSearchQuery] = useState(''); const [selectedTag, setSelectedTag] = useState(null); const [pendingZipFile, setPendingZipFile] = useState(null); - - const isTemporarySite = activeSite?.metadata.storage === 'none'; + const [pendingZipTargetSlug, setPendingZipTargetSlug] = useState< + string | null + >(null); useEffect(() => { - if (!pendingZipFile || !isTemporarySite || !playground) { + if ( + !pendingZipFile || + !playground || + !activeSite || + activeSite.slug !== pendingZipTargetSlug + ) { return; } @@ -124,19 +131,24 @@ export function SavedPlaygroundsOverlay({ ); } finally { setPendingZipFile(null); + setPendingZipTargetSlug(null); if (zipFileInputRef.current) { zipFileInputRef.current.value = ''; } } }; doImport(); - }, [pendingZipFile, isTemporarySite, playground, onClose]); + }, [pendingZipFile, pendingZipTargetSlug, activeSite, playground, onClose]); - async function switchToTemporarySite() { - if (temporarySite) { - await sitesAPI.setActiveSite(temporarySite.slug); - } else { - redirectTo(PlaygroundRoute.newTemporarySite()); + async function createSiteForImport() { + try { + return await sitesAPI.createNewSavedSite(); + } catch { + if (temporarySite) { + await sitesAPI.setActiveSite(temporarySite.slug); + return temporarySite.slug; + } + return await sitesAPI.createNewTemporarySite(); } } @@ -144,37 +156,18 @@ export function SavedPlaygroundsOverlay({ const file = e.target.files?.[0]; if (!file) return; - if (!isTemporarySite) { - setPendingZipFile(file); - switchToTemporarySite(); - return; - } - - if (!playground) { - alert( - 'No active Playground to import into. Please create one first.' - ); - return; - } - try { - await importWordPressFiles(playground, { wordPressFilesZip: file }); - setTimeout(async () => { - await playground.goTo('/'); - }, 200); - alert( - 'File imported! This Playground instance has been updated and will refresh shortly.' - ); - onClose(); + const targetSlug = await createSiteForImport(); + setPendingZipTargetSlug(targetSlug); + setPendingZipFile(file); } catch (error) { logger.error(error); alert( - 'Unable to import file. Is it a valid WordPress Playground export?' + 'No active Playground to import into. Please create one first.' ); - } - - if (zipFileInputRef.current) { - zipFileInputRef.current.value = ''; + if (zipFileInputRef.current) { + zipFileInputRef.current.value = ''; + } } }; @@ -263,7 +256,7 @@ export function SavedPlaygroundsOverlay({ function previewBlueprint(blueprintPath: BlueprintsIndexEntry['path']) { dispatch(setSiteManagerOpen(false)); redirectTo( - PlaygroundRoute.newTemporarySite({ + PlaygroundRoute.newSite({ query: { name: 'Blueprint preview', 'blueprint-url': `https://raw.githubusercontent.com/WordPress/blueprints/trunk/${blueprintPath.replace( @@ -278,7 +271,7 @@ export function SavedPlaygroundsOverlay({ function createVanillaSite() { dispatch(setSiteManagerOpen(false)); - redirectTo(PlaygroundRoute.newTemporarySite()); + redirectTo(PlaygroundRoute.newSite()); onClose(); } @@ -312,10 +305,8 @@ export function SavedPlaygroundsOverlay({ id: 'github', title: 'From GitHub', iconComponent: GitHubIcon, - onClick: () => { - if (!isTemporarySite) { - switchToTemporarySite(); - } + onClick: async () => { + await createSiteForImport(); modalDispatch(setActiveModal(modalSlugs.GITHUB_IMPORT)); }, disabled: offline, @@ -522,6 +513,19 @@ export function SavedPlaygroundsOverlay({ style={{ display: 'none' }} /> +
+ +
@@ -612,134 +616,144 @@ export function SavedPlaygroundsOverlay({ )} - -
-
- -
- {storedSites.map((site) => { - const isSelected = site.slug === activeSite?.slug; - return ( -
- +
+ {storedSites.map((site) => { + const isSelected = + site.slug === activeSite?.slug; + return ( +
-
- {site.metadata.logo ? ( - - ) : ( - - )} -
-
- - {site.metadata.name} - - {site.metadata.whenCreated && ( + - - {({ onClose: closeMenu }) => ( - <> - - - handleRenameSite( - site, - closeMenu - ) - } - > - Rename - - - - - handleDeleteSite( - site, - closeMenu - ) + css.siteRowDate } > - Delete - - - - )} - -
- ); - })} -
- + Created{' '} + {new Date( + site.metadata + .whenCreated + ).toLocaleDateString( + undefined, + { + year: 'numeric', + month: 'short', + day: 'numeric', + } + )} + + )} +
+ + + {({ onClose: closeMenu }) => ( + <> + + + handleRenameSite( + site, + closeMenu + ) + } + > + Rename + + + + + handleDeleteSite( + site, + closeMenu + ) + } + > + Delete + + + + )} + +
+ ); + })} + +
+
); diff --git a/packages/playground/website/src/components/saved-playgrounds-overlay/style.module.css b/packages/playground/website/src/components/saved-playgrounds-overlay/style.module.css index 0a7d8ca7c81..01a360b68ef 100644 --- a/packages/playground/website/src/components/saved-playgrounds-overlay/style.module.css +++ b/packages/playground/website/src/components/saved-playgrounds-overlay/style.module.css @@ -77,6 +77,32 @@ border-radius: 8px; } +.quickNav { + display: flex; + justify-content: center; + gap: 8px; + padding: 10px 24px; + border-bottom: 1px solid #2c3338; + background: #1e1e1e; + flex-shrink: 0; +} + +.quickNavButton { + background: transparent; + border: 1px solid #3c4349; + border-radius: 4px; + color: #e5e6e6; + cursor: pointer; + font: inherit; + font-size: 13px; + padding: 6px 12px; +} + +.quickNavButton:hover { + background: #2c3338; + color: #fff; +} + /* Creation row - horizontal pills */ .creationRow { display: flex; diff --git a/packages/playground/website/src/components/site-manager/blueprints-panel/index.tsx b/packages/playground/website/src/components/site-manager/blueprints-panel/index.tsx index 380081c6e09..1f2301349e3 100644 --- a/packages/playground/website/src/components/site-manager/blueprints-panel/index.tsx +++ b/packages/playground/website/src/components/site-manager/blueprints-panel/index.tsx @@ -64,7 +64,7 @@ export function BlueprintsPanel({ function previewBlueprint(blueprintPath: BlueprintsIndexEntry['path']) { dispatch(setSiteManagerOpen(false)); redirectTo( - PlaygroundRoute.newTemporarySite({ + PlaygroundRoute.newSite({ query: { name: 'Blueprint preview', // Explicitly do not use joinPaths() here as it normalizes the input and diff --git a/packages/playground/website/src/lib/state/redux/site-management-api-middleware.ts b/packages/playground/website/src/lib/state/redux/site-management-api-middleware.ts index 09b97570081..e8d66624b34 100644 --- a/packages/playground/website/src/lib/state/redux/site-management-api-middleware.ts +++ b/packages/playground/website/src/lib/state/redux/site-management-api-middleware.ts @@ -12,6 +12,7 @@ import { updateSiteMetadata, removeSite, setTemporarySiteSpec, + setStoredSiteSpec, deriveSiteNameFromSlug, } from './slice-sites'; import { randomSiteName } from './random-site-name'; @@ -19,6 +20,7 @@ import { persistTemporarySite } from './persist-temporary-site'; import { selectClientBySiteSlug } from './slice-clients'; import type { PlaygroundClient } from '@wp-playground/remote'; import type { AllPHPVersion } from '@php-wasm/universal'; +import { isOpfsAvailable } from '../opfs/opfs-site-storage'; export interface SiteSettings { phpVersion?: AllPHPVersion; @@ -131,6 +133,19 @@ export interface PlaygroundSitesAPI { siteSlug?: string, settings?: SiteSettings ): Promise; + + /** + * Creates a new browser-stored site and boots it. + * + * @param siteSlug Optional slug hint. A random name is + * generated when omitted. + * @param settings Optional site settings. + * @returns The new site's slug. + */ + createNewSavedSite( + siteSlug?: string, + settings?: SiteSettings + ): Promise; } export const siteManagementMiddleware = createListenerMiddleware(); @@ -373,10 +388,61 @@ export function createSitesAPI( await api.setActiveSite(newSiteInfo.slug); return newSiteInfo.slug; }, + + async createNewSavedSite( + requestedSiteSlug?: string, + settings?: SiteSettings + ) { + if (!isOpfsAvailable) { + throw new Error( + 'Cannot create a saved Playground because browser storage is not available.' + ); + } + const siteName = requestedSiteSlug + ? deriveSiteNameFromSlug(requestedSiteSlug) + : randomSiteName(); + const url = getUrlWithSettings(settings); + const newSiteInfo = await dispatch( + setStoredSiteSpec(siteName, url, requestedSiteSlug) + ); + await api.setActiveSite(newSiteInfo.slug); + return newSiteInfo.slug; + }, }; return api; } +function getUrlWithSettings(settings?: SiteSettings) { + const url = new URL(window.location.href); + url.searchParams.delete('random'); + url.searchParams.delete('site-slug'); + url.searchParams.delete('storage'); + if (settings) { + if (settings.phpVersion !== undefined) { + url.searchParams.set('php', settings.phpVersion); + } + if (settings.wpVersion !== undefined) { + url.searchParams.set('wp', settings.wpVersion); + } + if (settings.networking !== undefined) { + url.searchParams.set( + 'networking', + settings.networking ? 'yes' : 'no' + ); + } + if (settings.language !== undefined) { + url.searchParams.set('language', settings.language); + } + if (settings.multisite !== undefined) { + url.searchParams.set( + 'multisite', + settings.multisite ? 'yes' : 'no' + ); + } + } + return url; +} + /** * Once OPFS sites have loaded, expose the site management API on * `window.playgroundSites` and, when the MCP query-arg is present, diff --git a/packages/playground/website/src/lib/state/redux/site-slug.ts b/packages/playground/website/src/lib/state/redux/site-slug.ts new file mode 100644 index 00000000000..4b077e2094e --- /dev/null +++ b/packages/playground/website/src/lib/state/redux/site-slug.ts @@ -0,0 +1,29 @@ +export function normalizeSiteSlug(slug: string) { + return ( + slug + .toLowerCase() + .trim() + .replaceAll(/\s+/g, '-') + .replaceAll(/[^a-z0-9_-]/g, '-') + .replaceAll(/-+/g, '-') + .replaceAll(/^-|-$/g, '') || 'playground' + ); +} + +export function getUniqueSiteSlug( + preferredSlug: string, + unavailableSlugs: Iterable +) { + const unavailable = new Set(unavailableSlugs); + const baseSlug = normalizeSiteSlug(preferredSlug); + if (!unavailable.has(baseSlug)) { + return baseSlug; + } + let suffix = 2; + let candidate = `${baseSlug}-${suffix}`; + while (unavailable.has(candidate)) { + suffix++; + candidate = `${baseSlug}-${suffix}`; + } + return candidate; +} diff --git a/packages/playground/website/src/lib/state/redux/slice-sites.spec.ts b/packages/playground/website/src/lib/state/redux/slice-sites.spec.ts new file mode 100644 index 00000000000..e215e486dca --- /dev/null +++ b/packages/playground/website/src/lib/state/redux/slice-sites.spec.ts @@ -0,0 +1,23 @@ +import { getUniqueSiteSlug, normalizeSiteSlug } from './site-slug'; + +describe('site slug helpers', () => { + it('normalizes site names into stable slugs', () => { + expect(normalizeSiteSlug(' My WordPress Playground! ')).toBe( + 'my-wordpress-playground' + ); + }); + + it('uses a fallback slug when the preferred value has no usable characters', () => { + expect(normalizeSiteSlug('!!!')).toBe('playground'); + }); + + it('appends a suffix to avoid slug collisions', () => { + expect( + getUniqueSiteSlug('Demo Site', [ + 'demo-site', + 'demo-site-2', + 'other-site', + ]) + ).toBe('demo-site-3'); + }); +}); diff --git a/packages/playground/website/src/lib/state/redux/slice-sites.ts b/packages/playground/website/src/lib/state/redux/slice-sites.ts index a783d5f26cc..be745c3a115 100644 --- a/packages/playground/website/src/lib/state/redux/slice-sites.ts +++ b/packages/playground/website/src/lib/state/redux/slice-sites.ts @@ -25,6 +25,10 @@ import { logger } from '@php-wasm/logger'; import { setActiveSiteError, type SiteError } from './slice-ui'; import { RecommendedPHPVersion } from '@wp-playground/common'; import { findFirewallErrorInCauseChain } from './error-utils'; +import { getUniqueSiteSlug, normalizeSiteSlug } from './site-slug'; + +const DEFAULT_BLUEPRINT = + 'https://raw.githubusercontent.com/WordPress/blueprints/trunk/blueprints/welcome/blueprint.json'; /** * The Site model used to represent a site within Playground. @@ -113,7 +117,7 @@ export const getSitesLoadingState = (state: { }) => state.sites.opfsSitesLoadingState; export function deriveSlugFromSiteName(name: string) { - return name.toLowerCase().replaceAll(' ', '-'); + return normalizeSiteSlug(name); } export function deriveSiteNameFromSlug(slug: string) { return slug @@ -343,15 +347,11 @@ export function setTemporarySiteSpec( } } - // Then create a new temporary site - const defaultBlueprint = - 'https://raw.githubusercontent.com/WordPress/blueprints/trunk/blueprints/welcome/blueprint.json'; - let resolvedBlueprint: ResolvedBlueprint | undefined = undefined; try { resolvedBlueprint = await resolveBlueprintFromURL( playgroundUrlWithQueryApiArgs, - defaultBlueprint + DEFAULT_BLUEPRINT ); } catch (e) { logger.error( @@ -374,17 +374,10 @@ export function setTemporarySiteSpec( } try { - const reflection = await BlueprintReflection.create( - resolvedBlueprint.blueprint + resolvedBlueprint = await prepareResolvedBlueprint( + resolvedBlueprint, + playgroundUrlWithQueryApiArgs ); - if (reflection.getVersion() === 1) { - resolvedBlueprint.blueprint = await applyQueryOverrides( - resolvedBlueprint.blueprint, - playgroundUrlWithQueryApiArgs.searchParams - ); - } - - // Compute the runtime configuration based on the resolved Blueprint: const newSiteInfo: SiteInfo = { slug: siteSlug, originalUrlParams: newSiteUrlParams, @@ -417,6 +410,80 @@ export function setTemporarySiteSpec( }; } +/** + * Creates a new browser-stored site in OPFS and in the redux state. + */ +export function setStoredSiteSpec( + siteName: string, + playgroundUrlWithQueryApiArgs: URL, + preferredSlug?: string +) { + return async ( + dispatch: PlaygroundDispatch, + getState: () => PlaygroundReduxState + ) => { + const siteSlug = getUniqueSiteSlug( + preferredSlug || deriveSlugFromSiteName(siteName), + selectSiteSlugs(getState()) + ); + const originalUrlParams = { + searchParams: parseSearchParams( + playgroundUrlWithQueryApiArgs.searchParams + ), + hash: playgroundUrlWithQueryApiArgs.hash, + }; + + const resolvedBlueprint = await resolveSiteBlueprintFromUrl( + playgroundUrlWithQueryApiArgs + ); + const newSiteInfo: SiteInfo = { + slug: siteSlug, + originalUrlParams, + metadata: { + name: siteName, + id: crypto.randomUUID(), + whenCreated: Date.now(), + storage: 'opfs' as const, + originalBlueprint: resolvedBlueprint.blueprint, + originalBlueprintSource: resolvedBlueprint.source!, + runtimeConfiguration: await resolveRuntimeConfiguration( + resolvedBlueprint.blueprint + )!, + }, + }; + + await dispatch(addSite(newSiteInfo)); + return newSiteInfo; + }; +} + +async function resolveSiteBlueprintFromUrl(playgroundUrlWithQueryApiArgs: URL) { + const resolvedBlueprint = await resolveBlueprintFromURL( + playgroundUrlWithQueryApiArgs, + DEFAULT_BLUEPRINT + ); + return prepareResolvedBlueprint( + resolvedBlueprint, + playgroundUrlWithQueryApiArgs + ); +} + +async function prepareResolvedBlueprint( + resolvedBlueprint: ResolvedBlueprint, + playgroundUrlWithQueryApiArgs: URL +) { + const reflection = await BlueprintReflection.create( + resolvedBlueprint.blueprint + ); + if (reflection.getVersion() === 1) { + resolvedBlueprint.blueprint = await applyQueryOverrides( + resolvedBlueprint.blueprint, + playgroundUrlWithQueryApiArgs.searchParams + ); + } + return resolvedBlueprint; +} + function parseSearchParams(searchParams: URLSearchParams) { const params: Record = {}; for (const key of searchParams.keys()) { diff --git a/packages/playground/website/src/lib/state/url/router.spec.ts b/packages/playground/website/src/lib/state/url/router.spec.ts index a202ddc7eae..07c54c7f07b 100644 --- a/packages/playground/website/src/lib/state/url/router.spec.ts +++ b/packages/playground/website/src/lib/state/url/router.spec.ts @@ -1,11 +1,15 @@ -import { parseBlueprint } from './router'; +import { + isTemporaryStorageRequested, + parseBlueprint, + PlaygroundRoute, +} from './router'; import { decodeBlueprintHash } from './decode-blueprint-hash'; const toBase64 = (s: string) => typeof btoa === 'function' ? btoa(s) : // eslint-disable-next-line @typescript-eslint/no-explicit-any - (globalThis as any).Buffer.from(s, 'utf-8').toString('base64'); + (globalThis as any).Buffer.from(s, 'utf-8').toString('base64'); // `parseBlueprint` reaches into `window.atob` via the existing // `decodeBase64ToString` helper. The default vitest environment for this @@ -13,7 +17,9 @@ const toBase64 = (s: string) => // eslint-disable-next-line @typescript-eslint/no-explicit-any const g = globalThis as any; if (typeof g.window === 'undefined') { - g.window = { atob: (s: string) => Buffer.from(s, 'base64').toString('binary') }; + g.window = { + atob: (s: string) => Buffer.from(s, 'base64').toString('binary'), + }; } describe('decodeBlueprintHash', () => { @@ -87,7 +93,9 @@ describe('parseBlueprint', () => { }); it('throws a descriptive error for invalid JSON and includes the underlying message', () => { - expect(() => parseBlueprint('{not json')).toThrow(/Invalid blueprint\./); + expect(() => parseBlueprint('{not json')).toThrow( + /Invalid blueprint\./ + ); expect(() => parseBlueprint('{not json')).toThrow( /Invalid blueprint\.\s+\S/ ); @@ -98,3 +106,34 @@ describe('parseBlueprint', () => { expect(() => parseBlueprint(halfDecoded)).toThrow(/double-encoded/); }); }); + +describe('PlaygroundRoute site creation routes', () => { + it('marks new temporary site URLs with storage=temp', () => { + const url = PlaygroundRoute.newTemporarySite( + {}, + 'https://playground.test/website-server/' + ); + expect(new URL(url).searchParams.get('storage')).toBe('temp'); + }); + + it('does not mark default new site URLs as temporary', () => { + const url = PlaygroundRoute.newSite( + {}, + 'https://playground.test/website-server/?storage=temp' + ); + expect(new URL(url).searchParams.get('storage')).toBeNull(); + }); + + it('detects explicit temporary storage requests', () => { + expect( + isTemporaryStorageRequested( + 'https://playground.test/website-server/?storage=temp' + ) + ).toBe(true); + expect( + isTemporaryStorageRequested( + 'https://playground.test/website-server/' + ) + ).toBe(false); + }); +}); diff --git a/packages/playground/website/src/lib/state/url/router.ts b/packages/playground/website/src/lib/state/url/router.ts index fb23c803a12..370d5d9c14e 100644 --- a/packages/playground/website/src/lib/state/url/router.ts +++ b/packages/playground/website/src/lib/state/url/router.ts @@ -23,6 +23,7 @@ interface QueryAPIParams { url?: string; 'blueprint-url'?: string; 'page-title'?: string; + storage?: 'temp'; } /** @@ -58,7 +59,10 @@ export function parseBlueprint(rawData: string) { * base64-decode-then-parse error if the input looks base64-shaped, * otherwise the plain JSON.parse error. */ -function formatInvalidBlueprintError(rawData: string, errors: unknown[]): string { +function formatInvalidBlueprintError( + rawData: string, + errors: unknown[] +): string { const looksLikeBase64 = /^[A-Za-z0-9+/=]+$/.test(rawData.trim()); const primary = looksLikeBase64 && errors[1] ? errors[1] : errors[0]; const detail = @@ -117,6 +121,7 @@ export class PlaygroundRoute { { searchParams: { ...query, + storage: 'temp', // Ensure a part of the URL is unique so we can still // reload the temporary site even if its configuration // hasn't changed. @@ -127,6 +132,27 @@ export class PlaygroundRoute { 'replace' ); } + static newSite( + config: { + query?: QueryAPIParams; + hash?: string; + } = {}, + baseUrl: string = window.location.href + ) { + const query = + (config.query as Record) || {}; + return updateUrl( + baseUrl, + { + searchParams: { + ...query, + random: Math.random().toString(36).substring(2, 15), + }, + hash: config.hash, + }, + 'replace' + ); + } } /** @@ -140,6 +166,13 @@ export function isSaveDisabledByQueryParam(): boolean { ); } +/** + * Checks if the URL explicitly asks for a temporary Playground. + */ +export function isTemporaryStorageRequested(url = document.location.href) { + return new URL(url).searchParams.get('storage') === 'temp'; +} + /** * Checks if the MCP server bridge is enabled via the `?mcp=yes` query parameter. */ From fc0efb36447d4dc4e64b69a7dda2b1aae1986582 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Sun, 17 May 2026 21:30:40 +0200 Subject: [PATCH 02/47] Fix storage git internals imports --- package-lock.json | 4 +- packages/playground/storage/package.json | 4 +- .../src/lib/git-create-dotgit-directory.ts | 140 +- .../storage/src/lib/git-sparse-checkout.ts | 20 +- .../src/lib/isomorphic-git-internals.ts | 1949 +++++++++++++++++ .../storage/src/lib/isomorphic-git.d.ts | 127 -- .../website/playwright/e2e/website-ui.spec.ts | 5 +- 7 files changed, 2076 insertions(+), 173 deletions(-) create mode 100644 packages/playground/storage/src/lib/isomorphic-git-internals.ts delete mode 100644 packages/playground/storage/src/lib/isomorphic-git.d.ts diff --git a/package-lock.json b/package-lock.json index b4151f14cd9..a00fb88496d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -54299,7 +54299,9 @@ "version": "3.1.33", "license": "GPL-2.0-or-later", "dependencies": { - "pako": "^1.0.10" + "crc-32": "^1.2.0", + "pako": "^1.0.10", + "sha.js": "^2.4.12" } }, "packages/playground/sync": { diff --git a/packages/playground/storage/package.json b/packages/playground/storage/package.json index 5a33bd36a15..6e98fc704b1 100644 --- a/packages/playground/storage/package.json +++ b/packages/playground/storage/package.json @@ -36,6 +36,8 @@ "type": "module", "types": "index.d.ts", "dependencies": { - "pako": "^1.0.10" + "crc-32": "^1.2.0", + "pako": "^1.0.10", + "sha.js": "^2.4.12" } } diff --git a/packages/playground/storage/src/lib/git-create-dotgit-directory.ts b/packages/playground/storage/src/lib/git-create-dotgit-directory.ts index 21ae6d4b356..e43e5399ef6 100644 --- a/packages/playground/storage/src/lib/git-create-dotgit-directory.ts +++ b/packages/playground/storage/src/lib/git-create-dotgit-directory.ts @@ -1,4 +1,3 @@ -import { GitIndex } from 'isomorphic-git/src/models/GitIndex.js'; import type { SparseCheckoutObject } from './git-sparse-checkout'; import pako from 'pako'; const deflate = pako.deflate; @@ -12,6 +11,23 @@ type GitHeadInfo = { tagName?: string; }; +type GitIndexEntry = { + filepath: string; + oid: string; + stats: { + ctimeSeconds: number; + ctimeNanoseconds: number; + mtimeSeconds: number; + mtimeNanoseconds: number; + dev: number; + ino: number; + mode: number; + uid: number; + gid: number; + size: number; + }; +}; + const FULL_SHA_REGEX = /^[0-9a-f]{40}$/i; /** @@ -181,12 +197,10 @@ export async function createDotGitDirectory({ if (headInfo.branchRef && headInfo.branchName) { gitFiles['.git/logs/HEAD'] = `ref: ${headInfo.branchRef}\n`; gitFiles[`.git/${headInfo.branchRef}`] = `${commitHash}\n`; - gitFiles[ - `.git/refs/remotes/origin/${headInfo.branchName}` - ] = `${commitHash}\n`; - gitFiles[ - '.git/refs/remotes/origin/HEAD' - ] = `ref: refs/remotes/origin/${headInfo.branchName}\n`; + gitFiles[`.git/refs/remotes/origin/${headInfo.branchName}`] = + `${commitHash}\n`; + gitFiles['.git/refs/remotes/origin/HEAD'] = + `ref: refs/remotes/origin/${headInfo.branchName}\n`; } if (headInfo.tagName) { @@ -196,33 +210,91 @@ export async function createDotGitDirectory({ // Use loose objects only, no packfiles Object.assign(gitFiles, await createLooseGitObjectFiles(objects)); - // Create the git index - const index = new GitIndex(); - for (const [path, oid] of Object.entries(fileOids)) { - // Remove the path prefix to get the working tree relative path - const workingTreePath = path - .substring(pathPrefix.length) - .replace(/^\/+/, ''); - index.insert({ - filepath: workingTreePath, - oid, - stats: { - ctimeSeconds: 0, - ctimeNanoseconds: 0, - mtimeSeconds: 0, - mtimeNanoseconds: 0, - dev: 0, - ino: 0, - mode: 0o100644, // Regular file - uid: 0, - gid: 0, - size: 0, - }, - }); - } - const indexBuffer = await index.toObject(); - // Convert Buffer to Uint8Array - copy the data to ensure it's a proper Uint8Array - gitFiles['.git/index'] = Uint8Array.from(indexBuffer); + const indexEntries = Object.entries(fileOids).map(([path, oid]) => ({ + filepath: path.substring(pathPrefix.length).replace(/^\/+/, ''), + oid, + stats: { + ctimeSeconds: 0, + ctimeNanoseconds: 0, + mtimeSeconds: 0, + mtimeNanoseconds: 0, + dev: 0, + ino: 0, + mode: 0o100644, + uid: 0, + gid: 0, + size: 0, + }, + })); + gitFiles['.git/index'] = await createGitIndex(indexEntries); return gitFiles; } + +async function createGitIndex(entries: GitIndexEntry[]) { + const sortedEntryBuffers = entries + .sort((a, b) => a.filepath.localeCompare(b.filepath)) + .map(createGitIndexEntry); + const header = new Uint8Array(12); + const headerView = new DataView(header.buffer); + header.set(new TextEncoder().encode('DIRC'), 0); + headerView.setUint32(4, 2); + headerView.setUint32(8, entries.length); + const body = concatUint8Arrays([header, ...sortedEntryBuffers]); + const checksum = new Uint8Array(await crypto.subtle.digest('SHA-1', body)); + return concatUint8Arrays([body, checksum]); +} + +function createGitIndexEntry({ filepath, oid, stats }: GitIndexEntry) { + const pathBytes = new TextEncoder().encode(filepath); + const length = Math.ceil((62 + pathBytes.length + 1) / 8) * 8; + const entry = new Uint8Array(length); + const view = new DataView(entry.buffer); + + view.setUint32(0, stats.ctimeSeconds); + view.setUint32(4, stats.ctimeNanoseconds); + view.setUint32(8, stats.mtimeSeconds); + view.setUint32(12, stats.mtimeNanoseconds); + view.setUint32(16, stats.dev); + view.setUint32(20, stats.ino); + view.setUint32(24, normalizeGitFileMode(stats.mode)); + view.setUint32(28, stats.uid); + view.setUint32(32, stats.gid); + view.setUint32(36, stats.size); + entry.set(hexToBytes(oid), 40); + view.setUint16(60, Math.min(pathBytes.length, 0xfff)); + entry.set(pathBytes, 62); + return entry; +} + +function normalizeGitFileMode(mode: number) { + let type = mode > 0 ? mode >> 12 : 0; + if (![0b0100, 0b1000, 0b1010, 0b1110].includes(type)) { + type = 0b1000; + } + let permissions = mode & 0o777; + permissions = permissions & 0b001001001 ? 0o755 : 0o644; + if (type !== 0b1000) { + permissions = 0; + } + return (type << 12) + permissions; +} + +function hexToBytes(hex: string) { + const bytes = new Uint8Array(hex.length / 2); + for (let i = 0; i < bytes.length; i++) { + bytes[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16); + } + return bytes; +} + +function concatUint8Arrays(arrays: Uint8Array[]) { + const length = arrays.reduce((sum, array) => sum + array.length, 0); + const result = new Uint8Array(length); + let offset = 0; + for (const array of arrays) { + result.set(array, offset); + offset += array.length; + } + return result; +} diff --git a/packages/playground/storage/src/lib/git-sparse-checkout.ts b/packages/playground/storage/src/lib/git-sparse-checkout.ts index 66f9e905100..38741dc667b 100644 --- a/packages/playground/storage/src/lib/git-sparse-checkout.ts +++ b/packages/playground/storage/src/lib/git-sparse-checkout.ts @@ -1,4 +1,5 @@ /* eslint-disable comment-length/limit-multi-line-comments */ +// @ts-nocheck /* * Import internal data parsers and structures from isomorphic-git. These @@ -8,15 +9,16 @@ * This file heavily relies on isomorphic-git internals to parse Git data formats * such as PACK, trees, deltas, etc. */ -import './isomorphic-git.d.ts'; -import { GitPktLine } from 'isomorphic-git/src/models/GitPktLine.js'; -import { GitTree } from 'isomorphic-git/src/models/GitTree.js'; -import { GitAnnotatedTag } from 'isomorphic-git/src/models/GitAnnotatedTag.js'; -import { GitCommit } from 'isomorphic-git/src/models/GitCommit.js'; -import { GitPackIndex } from 'isomorphic-git/src/models/GitPackIndex.js'; -import { collect } from 'isomorphic-git/src/internal-apis.js'; -import { parseUploadPackResponse } from 'isomorphic-git/src/wire/parseUploadPackResponse.js'; -import { ObjectTypeError } from 'isomorphic-git/src/errors/ObjectTypeError.js'; +import { + GitPktLine, + GitTree, + GitAnnotatedTag, + GitCommit, + GitPackIndex, + collect, + parseUploadPackResponse, + ObjectTypeError, +} from './isomorphic-git-internals'; import { Buffer as BufferPolyfill } from 'buffer'; /** diff --git a/packages/playground/storage/src/lib/isomorphic-git-internals.ts b/packages/playground/storage/src/lib/isomorphic-git-internals.ts new file mode 100644 index 00000000000..3c4b498578e --- /dev/null +++ b/packages/playground/storage/src/lib/isomorphic-git-internals.ts @@ -0,0 +1,1949 @@ +/* eslint-disable */ +// @ts-nocheck +/** + * Local copy of the small subset of isomorphic-git internals used by + * git-sparse-checkout.ts. The npm package does not publish these modules + * under isomorphic-git/src/*, so importing them directly breaks Vite builds. + * + * Source: isomorphic-git 1.37.6 (MIT). + */ +import crc32 from 'crc-32'; +import pako from 'pako'; +import Hash from 'sha.js/sha1.js'; + +// webpack://git/./src/utils/fromValue.js +// Convert a value to an Async Iterator +// This will be easier with async generator functions. +export function fromValue(value) { + let queue = [value]; + return { + next() { + return Promise.resolve({ + done: queue.length === 0, + value: queue.pop(), + }); + }, + return() { + queue = []; + return {}; + }, + [Symbol.asyncIterator]() { + return this; + }, + }; +} + +// webpack://git/./src/utils/getIterator.js +export function getIterator(iterable) { + if (iterable[Symbol.asyncIterator]) { + return iterable[Symbol.asyncIterator](); + } + if (iterable[Symbol.iterator]) { + return iterable[Symbol.iterator](); + } + if (iterable.next) { + return iterable; + } + return fromValue(iterable); +} + +// webpack://git/./src/utils/StreamReader.js +// inspired by 'gartal' but lighter-weight and more battle-tested. +export class StreamReader { + constructor(stream) { + // TODO: fix usage in bundlers before Buffer dependency is removed #1855 + if (typeof Buffer === 'undefined') { + throw new Error('Missing Buffer dependency'); + } + this.stream = getIterator(stream); + this.buffer = null; + this.cursor = 0; + this.undoCursor = 0; + this.started = false; + this._ended = false; + this._discardedBytes = 0; + } + + eof() { + return this._ended && this.cursor === this.buffer.length; + } + + tell() { + return this._discardedBytes + this.cursor; + } + + async byte() { + if (this.eof()) return; + if (!this.started) await this._init(); + if (this.cursor === this.buffer.length) { + await this._loadnext(); + if (this._ended) return; + } + this._moveCursor(1); + return this.buffer[this.undoCursor]; + } + + async chunk() { + if (this.eof()) return; + if (!this.started) await this._init(); + if (this.cursor === this.buffer.length) { + await this._loadnext(); + if (this._ended) return; + } + this._moveCursor(this.buffer.length); + return this.buffer.slice(this.undoCursor, this.cursor); + } + + async read(n) { + if (this.eof()) return; + if (!this.started) await this._init(); + if (this.cursor + n > this.buffer.length) { + this._trim(); + await this._accumulate(n); + } + this._moveCursor(n); + return this.buffer.slice(this.undoCursor, this.cursor); + } + + async skip(n) { + if (this.eof()) return; + if (!this.started) await this._init(); + if (this.cursor + n > this.buffer.length) { + this._trim(); + await this._accumulate(n); + } + this._moveCursor(n); + } + + async undo() { + this.cursor = this.undoCursor; + } + + async _next() { + this.started = true; + let { done, value } = await this.stream.next(); + if (done) { + this._ended = true; + if (!value) return Buffer.alloc(0); + } + if (value) { + value = Buffer.from(value); + } + return value; + } + + _trim() { + // Throw away parts of the buffer we don't need anymore + // assert(this.cursor <= this.buffer.length) + this.buffer = this.buffer.slice(this.undoCursor); + this.cursor -= this.undoCursor; + this._discardedBytes += this.undoCursor; + this.undoCursor = 0; + } + + _moveCursor(n) { + this.undoCursor = this.cursor; + this.cursor += n; + if (this.cursor > this.buffer.length) { + this.cursor = this.buffer.length; + } + } + + async _accumulate(n) { + if (this._ended) return; + // Expand the buffer until we have N bytes of data + // or we've reached the end of the stream + const buffers = [this.buffer]; + while (this.cursor + n > lengthBuffers(buffers)) { + const nextbuffer = await this._next(); + if (this._ended) break; + buffers.push(nextbuffer); + } + this.buffer = Buffer.concat(buffers); + } + + async _loadnext() { + this._discardedBytes += this.buffer.length; + this.undoCursor = 0; + this.cursor = 0; + this.buffer = await this._next(); + } + + async _init() { + this.buffer = await this._next(); + } +} + +// This helper function helps us postpone concatenating buffers, which +// would create intermediate buffer objects, +function lengthBuffers(buffers) { + return buffers.reduce((acc, buffer) => acc + buffer.length, 0); +} + +// webpack://git/./src/utils/padHex.js +export function padHex(b, n) { + const s = n.toString(16); + return '0'.repeat(b - s.length) + s; +} + +// webpack://git/./src/models/GitPktLine.js +/** +pkt-line Format +--------------- + +Much (but not all) of the payload is described around pkt-lines. + +A pkt-line is a variable length binary string. The first four bytes +of the line, the pkt-len, indicates the total length of the line, +in hexadecimal. The pkt-len includes the 4 bytes used to contain +the length's hexadecimal representation. + +A pkt-line MAY contain binary data, so implementers MUST ensure +pkt-line parsing/formatting routines are 8-bit clean. + +A non-binary line SHOULD BE terminated by an LF, which if present +MUST be included in the total length. Receivers MUST treat pkt-lines +with non-binary data the same whether or not they contain the trailing +LF (stripping the LF if present, and not complaining when it is +missing). + +The maximum length of a pkt-line's data component is 65516 bytes. +Implementations MUST NOT send pkt-line whose length exceeds 65520 +(65516 bytes of payload + 4 bytes of length data). + +Implementations SHOULD NOT send an empty pkt-line ("0004"). + +A pkt-line with a length field of 0 ("0000"), called a flush-pkt, +is a special case and MUST be handled differently than an empty +pkt-line ("0004"). + +---- + pkt-line = data-pkt / flush-pkt + + data-pkt = pkt-len pkt-payload + pkt-len = 4*(HEXDIG) + pkt-payload = (pkt-len - 4)*(OCTET) + + flush-pkt = "0000" +---- + +Examples (as C-style strings): + +---- + pkt-line actual value + --------------------------------- + "0006a\n" "a\n" + "0005a" "a" + "000bfoobar\n" "foobar\n" + "0004" "" +---- +*/ + +// I'm really using this more as a namespace. +// There's not a lot of "state" in a pkt-line + +export class GitPktLine { + static flush() { + return Buffer.from('0000', 'utf8'); + } + + static delim() { + return Buffer.from('0001', 'utf8'); + } + + static encode(line) { + if (typeof line === 'string') { + line = Buffer.from(line); + } + const length = line.length + 4; + const hexlength = padHex(4, length); + return Buffer.concat([Buffer.from(hexlength, 'utf8'), line]); + } + + static streamReader(stream) { + const reader = new StreamReader(stream); + return async function read() { + try { + let length = await reader.read(4); + if (length == null) return true; + length = parseInt(length.toString('utf8'), 16); + if (length === 0) return null; + if (length === 1) return null; // delim packets + const buffer = await reader.read(length - 4); + if (buffer == null) return true; + return buffer; + } catch (err) { + stream.error = err; + return true; + } + }; + } +} + +// webpack://git/./src/errors/BaseError.js +export class BaseError extends Error { + constructor(message) { + super(message); + // Setting this here allows TS to infer that all git errors have a `caller` property and + // that its type is string. + this.caller = ''; + } + + toJSON() { + // Error objects aren't normally serializable. So we do something about that. + return { + code: this.code, + data: this.data, + caller: this.caller, + message: this.message, + stack: this.stack, + }; + } + + fromJSON(json) { + const e = new BaseError(json.message); + e.code = json.code; + e.data = json.data; + e.caller = json.caller; + e.stack = json.stack; + return e; + } + + get isIsomorphicGitError() { + return true; + } +} + +// webpack://git/./src/errors/InternalError.js +export class InternalError extends BaseError { + /** + * @param {string} message + */ + constructor(message) { + super( + `An internal error caused this command to fail.\n\nIf you're not a developer, report the bug to the developers of the application you're using. If this is a bug in isomorphic-git then you should create a proper bug yourselves. The bug should include a minimal reproduction and details about the version and environment.\n\nPlease file a bug report at https://github.com/isomorphic-git/isomorphic-git/issues with this error message: ${message}` + ); + this.code = this.name = InternalError.code; + this.data = { message }; + } +} +/** @type {'InternalError'} */ +InternalError.code = 'InternalError'; + +// webpack://git/./src/errors/UnsafeFilepathError.js +export class UnsafeFilepathError extends BaseError { + /** + * @param {string} filepath + */ + constructor(filepath) { + super(`The filepath "${filepath}" contains unsafe character sequences`); + this.code = this.name = UnsafeFilepathError.code; + this.data = { filepath }; + } +} +/** @type {'UnsafeFilepathError'} */ +UnsafeFilepathError.code = 'UnsafeFilepathError'; + +// webpack://git/./src/utils/compareStrings.js +export function compareStrings(a, b) { + // https://stackoverflow.com/a/40355107/2168416 + return -(a < b) || +(a > b); +} + +// webpack://git/./src/utils/comparePath.js +export function comparePath(a, b) { + // https://stackoverflow.com/a/40355107/2168416 + return compareStrings(a.path, b.path); +} + +// webpack://git/./src/utils/compareTreeEntryPath.js +export function compareTreeEntryPath(a, b) { + // Git sorts tree entries as if there is a trailing slash on directory names. + return compareStrings(appendSlashIfDir(a), appendSlashIfDir(b)); +} + +function appendSlashIfDir(entry) { + return entry.mode === '040000' ? entry.path + '/' : entry.path; +} + +// webpack://git/./src/models/GitTree.js +/** + * + * @typedef {Object} TreeEntry + * @property {string} mode - the 6 digit hexadecimal mode + * @property {string} path - the name of the file or directory + * @property {string} oid - the SHA-1 object id of the blob or tree + * @property {'commit'|'blob'|'tree'} type - the type of object + */ + +function mode2type(mode) { + // prettier-ignore + switch (mode) { + case '040000': return 'tree' + case '100644': return 'blob' + case '100755': return 'blob' + case '120000': return 'blob' + case '160000': return 'commit' + } + throw new InternalError(`Unexpected GitTree entry mode: ${mode}`); +} + +function parseBuffer(buffer) { + const _entries = []; + let cursor = 0; + while (cursor < buffer.length) { + const space = buffer.indexOf(32, cursor); + if (space === -1) { + throw new InternalError( + `GitTree: Error parsing buffer at byte location ${cursor}: Could not find the next space character.` + ); + } + const nullchar = buffer.indexOf(0, cursor); + if (nullchar === -1) { + throw new InternalError( + `GitTree: Error parsing buffer at byte location ${cursor}: Could not find the next null character.` + ); + } + let mode = buffer.slice(cursor, space).toString('utf8'); + if (mode === '40000') mode = '040000'; // makes it line up neater in printed output + const type = mode2type(mode); + const path = buffer.slice(space + 1, nullchar).toString('utf8'); + + // Prevent malicious git repos from writing to "..\foo" on clone etc + if (path.includes('\\') || path.includes('/')) { + throw new UnsafeFilepathError(path); + } + + const oid = buffer.slice(nullchar + 1, nullchar + 21).toString('hex'); + cursor = nullchar + 21; + _entries.push({ mode, path, oid, type }); + } + return _entries; +} + +function limitModeToAllowed(mode) { + if (typeof mode === 'number') { + mode = mode.toString(8); + } + // tree + if (mode.match(/^0?4.*/)) return '040000'; // Directory + if (mode.match(/^1006.*/)) return '100644'; // Regular non-executable file + if (mode.match(/^1007.*/)) return '100755'; // Regular executable file + if (mode.match(/^120.*/)) return '120000'; // Symbolic link + if (mode.match(/^160.*/)) return '160000'; // Commit (git submodule reference) + throw new InternalError(`Could not understand file mode: ${mode}`); +} + +function nudgeIntoShape(entry) { + if (!entry.oid && entry.sha) { + entry.oid = entry.sha; // Github + } + entry.mode = limitModeToAllowed(entry.mode); // index + if (!entry.type) { + entry.type = mode2type(entry.mode); // index + } + return entry; +} + +export class GitTree { + constructor(entries) { + if (Buffer.isBuffer(entries)) { + this._entries = parseBuffer(entries); + } else if (Array.isArray(entries)) { + this._entries = entries.map(nudgeIntoShape); + } else { + throw new InternalError( + 'invalid type passed to GitTree constructor' + ); + } + // Tree entries are not sorted alphabetically in the usual sense (see `compareTreeEntryPath`) + // but it is important later on that these be sorted in the same order as they would be returned from readdir. + this._entries.sort(comparePath); + } + + static from(tree) { + return new GitTree(tree); + } + + render() { + return this._entries + .map( + (entry) => + `${entry.mode} ${entry.type} ${entry.oid} ${entry.path}` + ) + .join('\n'); + } + + toObject() { + // Adjust the sort order to match git's + const entries = [...this._entries]; + entries.sort(compareTreeEntryPath); + return Buffer.concat( + entries.map((entry) => { + const mode = Buffer.from(entry.mode.replace(/^0/, '')); + const space = Buffer.from(' '); + const path = Buffer.from(entry.path, 'utf8'); + const nullchar = Buffer.from([0]); + const oid = Buffer.from(entry.oid, 'hex'); + return Buffer.concat([mode, space, path, nullchar, oid]); + }) + ); + } + + /** + * @returns {TreeEntry[]} + */ + entries() { + return this._entries; + } + + *[Symbol.iterator]() { + for (const entry of this._entries) { + yield entry; + } + } +} + +// webpack://git/./src/utils/formatAuthor.js +export function formatAuthor({ name, email, timestamp, timezoneOffset }) { + timezoneOffset = formatTimezoneOffset(timezoneOffset); + return `${name} <${email}> ${timestamp} ${timezoneOffset}`; +} + +// The amount of effort that went into crafting these cases to handle +// -0 (just so we don't lose that information when parsing and reconstructing) +// but can also default to +0 was extraordinary. + +function formatTimezoneOffset(minutes) { + const sign = simpleSign(negateExceptForZero(minutes)); + minutes = Math.abs(minutes); + const hours = Math.floor(minutes / 60); + minutes -= hours * 60; + let strHours = String(hours); + let strMinutes = String(minutes); + if (strHours.length < 2) strHours = '0' + strHours; + if (strMinutes.length < 2) strMinutes = '0' + strMinutes; + return (sign === -1 ? '-' : '+') + strHours + strMinutes; +} + +function simpleSign(n) { + return Math.sign(n) || (Object.is(n, -0) ? -1 : 1); +} + +function negateExceptForZero(n) { + return n === 0 ? n : -n; +} + +// webpack://git/./src/utils/indent.js +export function indent(str) { + return ( + str + .trim() + .split('\n') + .map((x) => ' ' + x) + .join('\n') + '\n' + ); +} + +// webpack://git/./src/utils/normalizeNewlines.js +export function normalizeNewlines(str) { + // remove all + str = str.replace(/\r/g, ''); + // no extra newlines up front + str = str.replace(/^\n+/, ''); + // and a single newline at the end + str = str.replace(/\n+$/, '') + '\n'; + return str; +} + +// webpack://git/./src/utils/outdent.js +export function outdent(str) { + return str + .split('\n') + .map((x) => x.replace(/^ /, '')) + .join('\n'); +} + +// webpack://git/./src/utils/parseAuthor.js +export function parseAuthor(author) { + const [, name, email, timestamp, offset] = author.match( + /^(.*) <(.*)> (.*) (.*)$/ + ); + return { + name, + email, + timestamp: Number(timestamp), + timezoneOffset: parseTimezoneOffset(offset), + }; +} + +// The amount of effort that went into crafting these cases to handle +// -0 (just so we don't lose that information when parsing and reconstructing) +// but can also default to +0 was extraordinary. + +function parseTimezoneOffset(offset) { + let [, sign, hours, minutes] = offset.match(/(\+|-)(\d\d)(\d\d)/); + minutes = (sign === '+' ? 1 : -1) * (Number(hours) * 60 + Number(minutes)); + return negateExceptForZeroForParse(minutes); +} + +function negateExceptForZeroForParse(n) { + return n === 0 ? n : -n; +} + +// webpack://git/./src/models/GitCommit.js +export class GitCommit { + constructor(commit) { + if (typeof commit === 'string') { + this._commit = commit; + } else if (Buffer.isBuffer(commit)) { + this._commit = commit.toString('utf8'); + } else if (typeof commit === 'object') { + this._commit = GitCommit.render(commit); + } else { + throw new InternalError( + 'invalid type passed to GitCommit constructor' + ); + } + } + + static fromPayloadSignature({ payload, signature }) { + const headers = GitCommit.justHeaders(payload); + const message = GitCommit.justMessage(payload); + const commit = normalizeNewlines( + headers + '\ngpgsig' + indent(signature) + '\n' + message + ); + return new GitCommit(commit); + } + + static from(commit) { + return new GitCommit(commit); + } + + toObject() { + return Buffer.from(this._commit, 'utf8'); + } + + // Todo: allow setting the headers and message + headers() { + return this.parseHeaders(); + } + + // Todo: allow setting the headers and message + message() { + return GitCommit.justMessage(this._commit); + } + + parse() { + return Object.assign({ message: this.message() }, this.headers()); + } + + static justMessage(commit) { + return normalizeNewlines(commit.slice(commit.indexOf('\n\n') + 2)); + } + + static justHeaders(commit) { + return commit.slice(0, commit.indexOf('\n\n')); + } + + parseHeaders() { + const headers = GitCommit.justHeaders(this._commit).split('\n'); + const hs = []; + for (const h of headers) { + if (h[0] === ' ') { + // combine with previous header (without space indent) + hs[hs.length - 1] += '\n' + h.slice(1); + } else { + hs.push(h); + } + } + const obj = { + parent: [], + }; + for (const h of hs) { + const key = h.slice(0, h.indexOf(' ')); + const value = h.slice(h.indexOf(' ') + 1); + if (Array.isArray(obj[key])) { + obj[key].push(value); + } else { + obj[key] = value; + } + } + if (obj.author) { + obj.author = parseAuthor(obj.author); + } + if (obj.committer) { + obj.committer = parseAuthor(obj.committer); + } + return obj; + } + + static renderHeaders(obj) { + let headers = ''; + if (obj.tree) { + headers += `tree ${obj.tree}\n`; + } else { + headers += `tree 4b825dc642cb6eb9a060e54bf8d69288fbee4904\n`; // the null tree + } + if (obj.parent) { + if (obj.parent.length === undefined) { + throw new InternalError( + `commit 'parent' property should be an array` + ); + } + for (const p of obj.parent) { + headers += `parent ${p}\n`; + } + } + const author = obj.author; + headers += `author ${formatAuthor(author)}\n`; + const committer = obj.committer || obj.author; + headers += `committer ${formatAuthor(committer)}\n`; + if (obj.gpgsig) { + headers += 'gpgsig' + indent(obj.gpgsig); + } + return headers; + } + + static render(obj) { + return ( + GitCommit.renderHeaders(obj) + '\n' + normalizeNewlines(obj.message) + ); + } + + render() { + return this._commit; + } + + withoutSignature() { + const commit = normalizeNewlines(this._commit); + if (commit.indexOf('\ngpgsig') === -1) return commit; + const headers = commit.slice(0, commit.indexOf('\ngpgsig')); + const message = commit.slice( + commit.indexOf('-----END PGP SIGNATURE-----\n') + + '-----END PGP SIGNATURE-----\n'.length + ); + return normalizeNewlines(headers + '\n' + message); + } + + isolateSignature() { + const signature = this._commit.slice( + this._commit.indexOf('-----BEGIN PGP SIGNATURE-----'), + this._commit.indexOf('-----END PGP SIGNATURE-----') + + '-----END PGP SIGNATURE-----'.length + ); + return outdent(signature); + } + + static async sign(commit, sign, secretKey) { + const payload = commit.withoutSignature(); + const message = GitCommit.justMessage(commit._commit); + let { signature } = await sign({ payload, secretKey }); + // renormalize the line endings to the one true line-ending + signature = normalizeNewlines(signature); + const headers = GitCommit.justHeaders(commit._commit); + const signedCommit = + headers + '\n' + 'gpgsig' + indent(signature) + '\n' + message; + // return a new commit object + return GitCommit.from(signedCommit); + } +} + +// webpack://git/./src/models/GitAnnotatedTag.js +export class GitAnnotatedTag { + constructor(tag) { + if (typeof tag === 'string') { + this._tag = tag; + } else if (Buffer.isBuffer(tag)) { + this._tag = tag.toString('utf8'); + } else if (typeof tag === 'object') { + this._tag = GitAnnotatedTag.render(tag); + } else { + throw new InternalError( + 'invalid type passed to GitAnnotatedTag constructor' + ); + } + } + + static from(tag) { + return new GitAnnotatedTag(tag); + } + + static render(obj) { + return `object ${obj.object} +type ${obj.type} +tag ${obj.tag} +tagger ${formatAuthor(obj.tagger)} + +${obj.message} +${obj.gpgsig ? obj.gpgsig : ''}`; + } + + justHeaders() { + return this._tag.slice(0, this._tag.indexOf('\n\n')); + } + + message() { + const tag = this.withoutSignature(); + return tag.slice(tag.indexOf('\n\n') + 2); + } + + parse() { + return Object.assign(this.headers(), { + message: this.message(), + gpgsig: this.gpgsig(), + }); + } + + render() { + return this._tag; + } + + headers() { + const headers = this.justHeaders().split('\n'); + const hs = []; + for (const h of headers) { + if (h[0] === ' ') { + // combine with previous header (without space indent) + hs[hs.length - 1] += '\n' + h.slice(1); + } else { + hs.push(h); + } + } + const obj = {}; + for (const h of hs) { + const key = h.slice(0, h.indexOf(' ')); + const value = h.slice(h.indexOf(' ') + 1); + if (Array.isArray(obj[key])) { + obj[key].push(value); + } else { + obj[key] = value; + } + } + if (obj.tagger) { + obj.tagger = parseAuthor(obj.tagger); + } + if (obj.committer) { + obj.committer = parseAuthor(obj.committer); + } + return obj; + } + + withoutSignature() { + const tag = normalizeNewlines(this._tag); + if (tag.indexOf('\n-----BEGIN PGP SIGNATURE-----') === -1) return tag; + return tag.slice(0, tag.lastIndexOf('\n-----BEGIN PGP SIGNATURE-----')); + } + + gpgsig() { + if (this._tag.indexOf('\n-----BEGIN PGP SIGNATURE-----') === -1) return; + const signature = this._tag.slice( + this._tag.indexOf('-----BEGIN PGP SIGNATURE-----'), + this._tag.indexOf('-----END PGP SIGNATURE-----') + + '-----END PGP SIGNATURE-----'.length + ); + return normalizeNewlines(signature); + } + + payload() { + return this.withoutSignature() + '\n'; + } + + toObject() { + return Buffer.from(this._tag, 'utf8'); + } + + static async sign(tag, sign, secretKey) { + const payload = tag.payload(); + let { signature } = await sign({ payload, secretKey }); + // renormalize the line endings to the one true line-ending + signature = normalizeNewlines(signature); + const signedTag = payload + signature; + // return a new tag object + return GitAnnotatedTag.from(signedTag); + } +} + +// webpack://git/./src/models/GitObject.js +/** + * Represents a Git object and provides methods to wrap and unwrap Git objects + * according to the Git object format. + */ +export class GitObject { + /** + * Wraps a raw object with a Git header. + * + * @param {Object} params - The parameters for wrapping. + * @param {string} params.type - The type of the Git object (e.g., 'blob', 'tree', 'commit'). + * @param {Uint8Array} params.object - The raw object data to wrap. + * @returns {Uint8Array} The wrapped Git object as a single buffer. + */ + static wrap({ type, object }) { + const header = `${type} ${object.length}\x00`; + const headerLen = header.length; + const totalLength = headerLen + object.length; + + // Allocate a single buffer for the header and object, rather than create multiple buffers + const wrappedObject = new Uint8Array(totalLength); + for (let i = 0; i < headerLen; i++) { + wrappedObject[i] = header.charCodeAt(i); + } + wrappedObject.set(object, headerLen); + + return wrappedObject; + } + + /** + * Unwraps a Git object buffer into its type and raw object data. + * + * @param {Buffer|Uint8Array} buffer - The buffer containing the wrapped Git object. + * @returns {{ type: string, object: Buffer }} An object containing the type and the raw object data. + * @throws {InternalError} If the length specified in the header does not match the actual object length. + */ + static unwrap(buffer) { + const s = buffer.indexOf(32); // first space + const i = buffer.indexOf(0); // first null value + const type = buffer.slice(0, s).toString('utf8'); // get type of object + const length = buffer.slice(s + 1, i).toString('utf8'); // get type of object + const actualLength = buffer.length - (i + 1); + // verify length + if (parseInt(length) !== actualLength) { + throw new InternalError( + `Length mismatch: expected ${length} bytes but got ${actualLength} instead.` + ); + } + return { + type, + object: Buffer.from(buffer.slice(i + 1)), + }; + } +} + +// webpack://git/./src/utils/BufferCursor.js +// Modeled after https://github.com/tjfontaine/node-buffercursor +// but with the goal of being much lighter weight. +export class BufferCursor { + constructor(buffer) { + this.buffer = buffer; + this._start = 0; + } + + eof() { + return this._start >= this.buffer.length; + } + + tell() { + return this._start; + } + + seek(n) { + this._start = n; + } + + slice(n) { + const r = this.buffer.slice(this._start, this._start + n); + this._start += n; + return r; + } + + toString(enc, length) { + const r = this.buffer.toString(enc, this._start, this._start + length); + this._start += length; + return r; + } + + write(value, length, enc) { + const r = this.buffer.write(value, this._start, length, enc); + this._start += length; + return r; + } + + copy(source, start, end) { + const r = source.copy(this.buffer, this._start, start, end); + this._start += r; + return r; + } + + readUInt8() { + const r = this.buffer.readUInt8(this._start); + this._start += 1; + return r; + } + + writeUInt8(value) { + const r = this.buffer.writeUInt8(value, this._start); + this._start += 1; + return r; + } + + readUInt16BE() { + const r = this.buffer.readUInt16BE(this._start); + this._start += 2; + return r; + } + + writeUInt16BE(value) { + const r = this.buffer.writeUInt16BE(value, this._start); + this._start += 2; + return r; + } + + readUInt32BE() { + const r = this.buffer.readUInt32BE(this._start); + this._start += 4; + return r; + } + + writeUInt32BE(value) { + const r = this.buffer.writeUInt32BE(value, this._start); + this._start += 4; + return r; + } +} + +// webpack://git/./src/utils/applyDelta.js +/** + * @param {Buffer} delta + * @param {Buffer} source + * @returns {Buffer} + */ +export function applyDelta(delta, source) { + const reader = new BufferCursor(delta); + const sourceSize = readVarIntLE(reader); + + if (sourceSize !== source.byteLength) { + throw new InternalError( + `applyDelta expected source buffer to be ${sourceSize} bytes but the provided buffer was ${source.length} bytes` + ); + } + const targetSize = readVarIntLE(reader); + let target; + + const firstOp = readOp(reader, source); + // Speed optimization - return raw buffer if it's just single simple copy + if (firstOp.byteLength === targetSize) { + target = firstOp; + } else { + // Otherwise, allocate a fresh buffer and slices + target = Buffer.alloc(targetSize); + const writer = new BufferCursor(target); + writer.copy(firstOp); + + while (!reader.eof()) { + writer.copy(readOp(reader, source)); + } + + const tell = writer.tell(); + if (targetSize !== tell) { + throw new InternalError( + `applyDelta expected target buffer to be ${targetSize} bytes but the resulting buffer was ${tell} bytes` + ); + } + } + return target; +} + +function readVarIntLE(reader) { + let result = 0; + let shift = 0; + let byte = null; + do { + byte = reader.readUInt8(); + result |= (byte & 0b01111111) << shift; + shift += 7; + } while (byte & 0b10000000); + return result; +} + +function readCompactLE(reader, flags, size) { + let result = 0; + let shift = 0; + while (size--) { + if (flags & 0b00000001) { + result |= reader.readUInt8() << shift; + } + flags >>= 1; + shift += 8; + } + return result; +} + +function readOp(reader, source) { + /** @type {number} */ + const byte = reader.readUInt8(); + const COPY = 0b10000000; + const OFFS = 0b00001111; + const SIZE = 0b01110000; + if (byte & COPY) { + // copy consists of 4 byte offset, 3 byte size (in LE order) + const offset = readCompactLE(reader, byte & OFFS, 4); + let size = readCompactLE(reader, (byte & SIZE) >> 4, 3); + // Yup. They really did this optimization. + if (size === 0) size = 0x10000; + return source.slice(offset, offset + size); + } else { + // insert + return reader.slice(byte); + } +} + +// webpack://git/./src/utils/git-list-pack.js +// My version of git-list-pack - roughly 15x faster than the original +// It's used slightly differently - instead of returning a through stream it wraps a stream. +// (I tried to make it API identical, but that ended up being 2x slower than this version.) + +export async function listpack(stream, onData) { + const reader = new StreamReader(stream); + let PACK = await reader.read(4); + PACK = PACK.toString('utf8'); + if (PACK !== 'PACK') { + throw new InternalError(`Invalid PACK header '${PACK}'`); + } + + let version = await reader.read(4); + version = version.readUInt32BE(0); + if (version !== 2) { + throw new InternalError(`Invalid packfile version: ${version}`); + } + + let numObjects = await reader.read(4); + numObjects = numObjects.readUInt32BE(0); + // If (for some godforsaken reason) this is an empty packfile, abort now. + if (numObjects < 1) return; + + while (!reader.eof() && numObjects--) { + const offset = reader.tell(); + const { type, length, ofs, reference } = await parseHeader(reader); + const inflator = new pako.Inflate(); + while (!inflator.result) { + const chunk = await reader.chunk(); + if (!chunk) break; + inflator.push(chunk, false); + if (inflator.err) { + throw new InternalError(`Pako error: ${inflator.msg}`); + } + if (inflator.result) { + if (inflator.result.length !== length) { + throw new InternalError( + `Inflated object size is different from that stated in packfile.` + ); + } + + // Backtrack parser to where deflated data ends + await reader.undo(); + await reader.read(chunk.length - inflator.strm.avail_in); + const end = reader.tell(); + await onData({ + data: inflator.result, + type, + num: numObjects, + offset, + end, + reference, + ofs, + }); + } + } + } +} + +async function parseHeader(reader) { + // Object type is encoded in bits 654 + let byte = await reader.byte(); + const type = (byte >> 4) & 0b111; + // The length encoding get complicated. + // Last four bits of length is encoded in bits 3210 + let length = byte & 0b1111; + // Whether the next byte is part of the variable-length encoded number + // is encoded in bit 7 + if (byte & 0b10000000) { + let shift = 4; + do { + byte = await reader.byte(); + length |= (byte & 0b01111111) << shift; + shift += 7; + } while (byte & 0b10000000); + } + // Handle deltified objects + let ofs; + let reference; + if (type === 6) { + let shift = 0; + ofs = 0; + const bytes = []; + do { + byte = await reader.byte(); + ofs |= (byte & 0b01111111) << shift; + shift += 7; + bytes.push(byte); + } while (byte & 0b10000000); + reference = Buffer.from(bytes); + } + if (type === 7) { + const buf = await reader.read(20); + reference = buf; + } + return { type, length, ofs, reference }; +} + +// webpack://git/./src/utils/inflate.js +/* eslint-env node, browser */ +/* global DecompressionStream */ + +let supportsDecompressionStream = false; + +export async function inflate(buffer) { + if (supportsDecompressionStream === null) { + supportsDecompressionStream = testDecompressionStream(); + } + return supportsDecompressionStream + ? browserInflate(buffer) + : pako.inflate(buffer); +} + +async function browserInflate(buffer) { + const ds = new DecompressionStream('deflate'); + const d = new Blob([buffer]).stream().pipeThrough(ds); + return new Uint8Array(await new Response(d).arrayBuffer()); +} + +function testDecompressionStream() { + try { + const ds = new DecompressionStream('deflate'); + if (ds) return true; + } catch (_) { + // no bother + } + return false; +} + +// webpack://git/./src/utils/toHex.js +export function toHex(buffer) { + let hex = ''; + for (const byte of new Uint8Array(buffer)) { + if (byte < 16) hex += '0'; + hex += byte.toString(16); + } + return hex; +} + +// webpack://git/./src/utils/shasum.js +/* eslint-env node, browser */ + +let supportsSubtleSHA1 = null; + +export async function shasum(buffer) { + if (supportsSubtleSHA1 === null) { + supportsSubtleSHA1 = await testSubtleSHA1(); + } + return supportsSubtleSHA1 ? subtleSHA1(buffer) : shasumSync(buffer); +} + +// This is modeled after @dominictarr's "shasum" module, +// but without the 'json-stable-stringify' dependency and +// extra type-casting features. +function shasumSync(buffer) { + return new Hash().update(buffer).digest('hex'); +} + +async function subtleSHA1(buffer) { + const hash = await crypto.subtle.digest('SHA-1', buffer); + return toHex(hash); +} + +async function testSubtleSHA1() { + // I'm using a rather crude method of progressive enhancement, because + // some browsers that have crypto.subtle.digest don't actually implement SHA-1. + try { + const hash = await subtleSHA1(new Uint8Array([])); + return hash === 'da39a3ee5e6b4b0d3255bfef95601890afd80709'; + } catch (_) { + // no bother + } + return false; +} + +// webpack://git/./src/models/GitPackIndex.js +function decodeVarInt(reader) { + const bytes = []; + let byte = 0; + let multibyte = 0; + do { + byte = reader.readUInt8(); + // We keep bits 6543210 + const lastSeven = byte & 0b01111111; + bytes.push(lastSeven); + // Whether the next byte is part of the variable-length encoded number + // is encoded in bit 7 + multibyte = byte & 0b10000000; + } while (multibyte); + // Now that all the bytes are in big-endian order, + // alternate shifting the bits left by 7 and OR-ing the next byte. + // And... do a weird increment-by-one thing that I don't quite understand. + return bytes.reduce((a, b) => ((a + 1) << 7) | b, -1); +} + +// I'm pretty much copying this one from the git C source code, +// because it makes no sense. +function otherVarIntDecode(reader, startWith) { + let result = startWith; + let shift = 4; + let byte = null; + do { + byte = reader.readUInt8(); + result |= (byte & 0b01111111) << shift; + shift += 7; + } while (byte & 0b10000000); + return result; +} + +export class GitPackIndex { + constructor(stuff) { + Object.assign(this, stuff); + this.offsetCache = {}; + } + + static async fromIdx({ idx, getExternalRefDelta }) { + const reader = new BufferCursor(idx); + const magic = reader.slice(4).toString('hex'); + // Check for IDX v2 magic number + if (magic !== 'ff744f63') { + return; // undefined + } + const version = reader.readUInt32BE(); + if (version !== 2) { + throw new InternalError( + `Unable to read version ${version} packfile IDX. (Only version 2 supported)` + ); + } + if (idx.byteLength > 2048 * 1024 * 1024) { + throw new InternalError( + `To keep implementation simple, I haven't implemented the layer 5 feature needed to support packfiles > 2GB in size.` + ); + } + // Skip over fanout table + reader.seek(reader.tell() + 4 * 255); + // Get hashes + const size = reader.readUInt32BE(); + const hashes = []; + for (let i = 0; i < size; i++) { + const hash = reader.slice(20).toString('hex'); + hashes[i] = hash; + } + reader.seek(reader.tell() + 4 * size); + // Skip over CRCs + // Get offsets + const offsets = new Map(); + for (let i = 0; i < size; i++) { + offsets.set(hashes[i], reader.readUInt32BE()); + } + const packfileSha = reader.slice(20).toString('hex'); + return new GitPackIndex({ + hashes, + crcs: {}, + offsets, + packfileSha, + getExternalRefDelta, + }); + } + + static async fromPack({ pack, getExternalRefDelta, onProgress }) { + const listpackTypes = { + 1: 'commit', + 2: 'tree', + 3: 'blob', + 4: 'tag', + 6: 'ofs-delta', + 7: 'ref-delta', + }; + const offsetToObject = {}; + + // Older packfiles do NOT use the shasum of the pack itself, + // so it is recommended to just use whatever bytes are in the trailer. + // Source: https://github.com/git/git/commit/1190a1acf800acdcfd7569f87ac1560e2d077414 + const packfileSha = pack.slice(-20).toString('hex'); + + const hashes = []; + const crcs = {}; + const offsets = new Map(); + let totalObjectCount = null; + let lastPercent = null; + + await listpack( + [pack], + async ({ data, type, reference, offset, num }) => { + if (totalObjectCount === null) totalObjectCount = num; + const percent = Math.floor( + ((totalObjectCount - num) * 100) / totalObjectCount + ); + if (percent !== lastPercent) { + if (onProgress) { + await onProgress({ + phase: 'Receiving objects', + loaded: totalObjectCount - num, + total: totalObjectCount, + }); + } + } + lastPercent = percent; + // Change type from a number to a meaningful string + type = listpackTypes[type]; + + if (['commit', 'tree', 'blob', 'tag'].includes(type)) { + offsetToObject[offset] = { + type, + offset, + }; + } else if (type === 'ofs-delta') { + offsetToObject[offset] = { + type, + offset, + }; + } else if (type === 'ref-delta') { + offsetToObject[offset] = { + type, + offset, + }; + } + } + ); + + // We need to know the lengths of the slices to compute the CRCs. + const offsetArray = Object.keys(offsetToObject).map(Number); + for (const [i, start] of offsetArray.entries()) { + const end = + i + 1 === offsetArray.length + ? pack.byteLength - 20 + : offsetArray[i + 1]; + const o = offsetToObject[start]; + const crc = crc32.buf(pack.slice(start, end)) >>> 0; + o.end = end; + o.crc = crc; + } + + // We don't have the hashes yet. But we can generate them using the .readSlice function! + const p = new GitPackIndex({ + pack: Promise.resolve(pack), + packfileSha, + crcs, + hashes, + offsets, + getExternalRefDelta, + }); + + // Resolve deltas and compute the oids + lastPercent = null; + let count = 0; + const objectsByDepth = [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]; + for (let offset in offsetToObject) { + offset = Number(offset); + const percent = Math.floor((count * 100) / totalObjectCount); + if (percent !== lastPercent) { + if (onProgress) { + await onProgress({ + phase: 'Resolving deltas', + loaded: count, + total: totalObjectCount, + }); + } + } + count++; + lastPercent = percent; + + const o = offsetToObject[offset]; + if (o.oid) continue; + try { + p.readDepth = 0; + p.externalReadDepth = 0; + const { type, object } = await p.readSlice({ start: offset }); + objectsByDepth[p.readDepth] += 1; + const oid = await shasum(GitObject.wrap({ type, object })); + o.oid = oid; + hashes.push(oid); + offsets.set(oid, offset); + crcs[oid] = o.crc; + } catch (err) { + continue; + } + } + + hashes.sort(); + return p; + } + + async toBuffer() { + const buffers = []; + const write = (str, encoding) => { + buffers.push(Buffer.from(str, encoding)); + }; + // Write out IDX v2 magic number + write('ff744f63', 'hex'); + // Write out version number 2 + write('00000002', 'hex'); + // Write fanout table + const fanoutBuffer = new BufferCursor(Buffer.alloc(256 * 4)); + for (let i = 0; i < 256; i++) { + let count = 0; + for (const hash of this.hashes) { + if (parseInt(hash.slice(0, 2), 16) <= i) count++; + } + fanoutBuffer.writeUInt32BE(count); + } + buffers.push(fanoutBuffer.buffer); + // Write out hashes + for (const hash of this.hashes) { + write(hash, 'hex'); + } + // Write out crcs + const crcsBuffer = new BufferCursor( + Buffer.alloc(this.hashes.length * 4) + ); + for (const hash of this.hashes) { + crcsBuffer.writeUInt32BE(this.crcs[hash]); + } + buffers.push(crcsBuffer.buffer); + // Write out offsets + const offsetsBuffer = new BufferCursor( + Buffer.alloc(this.hashes.length * 4) + ); + for (const hash of this.hashes) { + offsetsBuffer.writeUInt32BE(this.offsets.get(hash)); + } + buffers.push(offsetsBuffer.buffer); + // Write out packfile checksum + write(this.packfileSha, 'hex'); + // Write out shasum + const totalBuffer = Buffer.concat(buffers); + const sha = await shasum(totalBuffer); + const shaBuffer = Buffer.alloc(20); + shaBuffer.write(sha, 'hex'); + return Buffer.concat([totalBuffer, shaBuffer]); + } + + async load({ pack }) { + this.pack = pack; + } + + async unload() { + this.pack = null; + } + + async read({ oid }) { + if (!this.offsets.get(oid)) { + if (this.getExternalRefDelta) { + this.externalReadDepth++; + return this.getExternalRefDelta(oid); + } else { + throw new InternalError( + `Could not read object ${oid} from packfile` + ); + } + } + const start = this.offsets.get(oid); + return this.readSlice({ start }); + } + + async readSlice({ start }) { + if (this.offsetCache[start]) { + return Object.assign({}, this.offsetCache[start]); + } + this.readDepth++; + const types = { + 0b0010000: 'commit', + 0b0100000: 'tree', + 0b0110000: 'blob', + 0b1000000: 'tag', + 0b1100000: 'ofs_delta', + 0b1110000: 'ref_delta', + }; + const pack = await this.pack; + if (!pack) { + throw new InternalError( + 'Could not read packfile data. The packfile may be missing, corrupted, or too large to read into memory.' + ); + } + const raw = pack.slice(start); + const reader = new BufferCursor(raw); + const byte = reader.readUInt8(); + // Object type is encoded in bits 654 + const btype = byte & 0b1110000; + let type = types[btype]; + if (type === undefined) { + throw new InternalError( + 'Unrecognized type: 0b' + btype.toString(2) + ); + } + // The length encoding get complicated. + // Last four bits of length is encoded in bits 3210 + const lastFour = byte & 0b1111; + let length = lastFour; + // Whether the next byte is part of the variable-length encoded number + // is encoded in bit 7 + const multibyte = byte & 0b10000000; + if (multibyte) { + length = otherVarIntDecode(reader, lastFour); + } + let base = null; + let object = null; + // Handle deltified objects + if (type === 'ofs_delta') { + const offset = decodeVarInt(reader); + const baseOffset = start - offset; + ({ object: base, type } = await this.readSlice({ + start: baseOffset, + })); + } + if (type === 'ref_delta') { + const oid = reader.slice(20).toString('hex'); + ({ object: base, type } = await this.read({ oid })); + } + // Handle undeltified objects + const buffer = raw.slice(reader.tell()); + object = Buffer.from(await inflate(buffer)); + // Assert that the object length is as expected. + if (object.byteLength !== length) { + throw new InternalError( + `Packfile told us object would have length ${length} but it had length ${object.byteLength}` + ); + } + if (base) { + object = Buffer.from(applyDelta(object, base)); + } + // Cache the result based on depth. + if (this.readDepth > 3) { + // hand tuned for speed / memory usage tradeoff + this.offsetCache[start] = { type, object }; + } + return { type, format: 'content', object }; + } +} + +// webpack://git/./src/errors/InvalidOidError.js +export class InvalidOidError extends BaseError { + /** + * @param {string} value + */ + constructor(value) { + super(`Expected a 40-char hex object id but saw "${value}".`); + this.code = this.name = InvalidOidError.code; + this.data = { value }; + } +} +/** @type {'InvalidOidError'} */ +InvalidOidError.code = 'InvalidOidError'; + +// webpack://git/./src/utils/FIFO.js +export class FIFO { + constructor() { + this._queue = []; + } + + write(chunk) { + if (this._ended) { + throw Error( + 'You cannot write to a FIFO that has already been ended!' + ); + } + if (this._waiting) { + const resolve = this._waiting; + this._waiting = null; + resolve({ value: chunk }); + } else { + this._queue.push(chunk); + } + } + + end() { + this._ended = true; + if (this._waiting) { + const resolve = this._waiting; + this._waiting = null; + resolve({ done: true }); + } + } + + destroy(err) { + this.error = err; + this.end(); + } + + async next() { + if (this._queue.length > 0) { + return { value: this._queue.shift() }; + } + if (this._ended) { + return { done: true }; + } + if (this._waiting) { + throw Error( + 'You cannot call read until the previous call to read has returned!' + ); + } + return new Promise((resolve) => { + this._waiting = resolve; + }); + } +} + +// webpack://git/./src/models/GitSideBand.js +/* +If 'side-band' or 'side-band-64k' capabilities have been specified by +the client, the server will send the packfile data multiplexed. + +Each packet starting with the packet-line length of the amount of data +that follows, followed by a single byte specifying the sideband the +following data is coming in on. + +In 'side-band' mode, it will send up to 999 data bytes plus 1 control +code, for a total of up to 1000 bytes in a pkt-line. In 'side-band-64k' +mode it will send up to 65519 data bytes plus 1 control code, for a +total of up to 65520 bytes in a pkt-line. + +The sideband byte will be a '1', '2' or a '3'. Sideband '1' will contain +packfile data, sideband '2' will be used for progress information that the +client will generally print to stderr and sideband '3' is used for error +information. + +If no 'side-band' capability was specified, the server will stream the +entire packfile without multiplexing. +*/ + +export class GitSideBand { + static demux(input) { + const read = GitPktLine.streamReader(input); + // And now for the ridiculous side-band or side-band-64k protocol + const packetlines = new FIFO(); + const packfile = new FIFO(); + const progress = new FIFO(); + // TODO: Use a proper through stream? + const nextBit = async function () { + const line = await read(); + // Skip over flush packets + if (line === null) return nextBit(); + // A made up convention to signal there's no more to read. + if (line === true) { + packetlines.end(); + progress.end(); + input.error ? packfile.destroy(input.error) : packfile.end(); + return; + } + // Examine first byte to determine which output "stream" to use + switch (line[0]) { + case 1: { + // pack data + packfile.write(line.slice(1)); + break; + } + case 2: { + // progress message + progress.write(line.slice(1)); + break; + } + case 3: { + // fatal error message just before stream aborts + const error = line.slice(1); + progress.write(error); + packetlines.end(); + progress.end(); + packfile.destroy(new Error(error.toString('utf8'))); + return; + } + default: { + // Not part of the side-band-64k protocol + packetlines.write(line); + } + } + // Careful not to blow up the stack. + // I think Promises in a tail-call position should be OK. + nextBit(); + }; + nextBit(); + return { + packetlines, + packfile, + progress, + }; + } + // static mux ({ + // protocol, // 'side-band' or 'side-band-64k' + // packetlines, + // packfile, + // progress, + // error + // }) { + // const MAX_PACKET_LENGTH = protocol === 'side-band-64k' ? 999 : 65519 + // let output = new PassThrough() + // packetlines.on('data', data => { + // if (data === null) { + // output.write(GitPktLine.flush()) + // } else { + // output.write(GitPktLine.encode(data)) + // } + // }) + // let packfileWasEmpty = true + // let packfileEnded = false + // let progressEnded = false + // let errorEnded = false + // let goodbye = Buffer.concat([ + // GitPktLine.encode(Buffer.from('010A', 'hex')), + // GitPktLine.flush() + // ]) + // packfile + // .on('data', data => { + // packfileWasEmpty = false + // const buffers = splitBuffer(data, MAX_PACKET_LENGTH) + // for (const buffer of buffers) { + // output.write( + // GitPktLine.encode(Buffer.concat([Buffer.from('01', 'hex'), buffer])) + // ) + // } + // }) + // .on('end', () => { + // packfileEnded = true + // if (!packfileWasEmpty) output.write(goodbye) + // if (progressEnded && errorEnded) output.end() + // }) + // progress + // .on('data', data => { + // const buffers = splitBuffer(data, MAX_PACKET_LENGTH) + // for (const buffer of buffers) { + // output.write( + // GitPktLine.encode(Buffer.concat([Buffer.from('02', 'hex'), buffer])) + // ) + // } + // }) + // .on('end', () => { + // progressEnded = true + // if (packfileEnded && errorEnded) output.end() + // }) + // error + // .on('data', data => { + // const buffers = splitBuffer(data, MAX_PACKET_LENGTH) + // for (const buffer of buffers) { + // output.write( + // GitPktLine.encode(Buffer.concat([Buffer.from('03', 'hex'), buffer])) + // ) + // } + // }) + // .on('end', () => { + // errorEnded = true + // if (progressEnded && packfileEnded) output.end() + // }) + // return output + // } +} + +// webpack://git/./src/utils/forAwait.js +// Currently 'for await' upsets my linters. +export async function forAwait(iterable, cb) { + const iter = getIterator(iterable); + while (true) { + const { value, done } = await iter.next(); + if (value) await cb(value); + if (done) break; + } + if (iter.return) iter.return(); +} + +// webpack://git/./src/wire/parseUploadPackResponse.js +export async function parseUploadPackResponse(stream) { + const { packetlines, packfile, progress } = GitSideBand.demux(stream); + const shallows = []; + const unshallows = []; + const acks = []; + let nak = false; + let done = false; + return new Promise((resolve, reject) => { + // Parse the response + forAwait(packetlines, (data) => { + const line = data.toString('utf8').trim(); + if (line.startsWith('shallow')) { + const oid = line.slice(-41).trim(); + if (oid.length !== 40) { + reject(new InvalidOidError(oid)); + } + shallows.push(oid); + } else if (line.startsWith('unshallow')) { + const oid = line.slice(-41).trim(); + if (oid.length !== 40) { + reject(new InvalidOidError(oid)); + } + unshallows.push(oid); + } else if (line.startsWith('ACK')) { + const [, oid, status] = line.split(' '); + acks.push({ oid, status }); + if (!status) done = true; + } else if (line.startsWith('NAK')) { + nak = true; + done = true; + } else { + done = true; + nak = true; + } + if (done) { + stream.error + ? reject(stream.error) + : resolve({ + shallows, + unshallows, + acks, + nak, + packfile, + progress, + }); + } + }).finally(() => { + if (!done) { + stream.error + ? reject(stream.error) + : resolve({ + shallows, + unshallows, + acks, + nak, + packfile, + progress, + }); + } + }); + }); +} + +// webpack://git/./src/errors/ObjectTypeError.js +export class ObjectTypeError extends BaseError { + /** + * @param {string} oid + * @param {'blob'|'commit'|'tag'|'tree'} actual + * @param {'blob'|'commit'|'tag'|'tree'} expected + * @param {string} [filepath] + */ + constructor(oid, actual, expected, filepath) { + super( + `Object ${oid} ${ + filepath ? `at ${filepath}` : '' + }was anticipated to be a ${expected} but it is a ${actual}.` + ); + this.code = this.name = ObjectTypeError.code; + this.data = { oid, actual, expected, filepath }; + } +} +/** @type {'ObjectTypeError'} */ +ObjectTypeError.code = 'ObjectTypeError'; + +// webpack://git/./src/utils/collect.js +export async function collect(iterable) { + let size = 0; + const buffers = []; + // This will be easier once `for await ... of` loops are available. + await forAwait(iterable, (value) => { + buffers.push(value); + size += value.byteLength; + }); + const result = new Uint8Array(size); + let nextIndex = 0; + for (const buffer of buffers) { + result.set(buffer, nextIndex); + nextIndex += buffer.byteLength; + } + return result; +} diff --git a/packages/playground/storage/src/lib/isomorphic-git.d.ts b/packages/playground/storage/src/lib/isomorphic-git.d.ts deleted file mode 100644 index f8c0857a816..00000000000 --- a/packages/playground/storage/src/lib/isomorphic-git.d.ts +++ /dev/null @@ -1,127 +0,0 @@ -declare module 'isomorphic-git/src/models/GitIndex.js' { - export class GitIndex { - constructor(entries?: Map, unmergedPaths?: Set); - insert(entry: { - filepath: string; - oid: string; - stats: { - ctimeSeconds: number; - ctimeNanoseconds: number; - mtimeSeconds: number; - mtimeNanoseconds: number; - dev: number; - ino: number; - mode: number; - uid: number; - gid: number; - size: number; - }; - }): void; - toObject(): Promise; - } -} - -declare module 'isomorphic-git/src/models/GitPktLine.js' { - export class GitPktLine { - static encode(data: string): Buffer; - static decode(data: Buffer): string; - static flush(): Buffer; - static delim(): Buffer; - } -} - -declare module 'isomorphic-git/src/models/GitTree.js' { - export class GitTree { - static from(buffer: Buffer): GitTree; - type: 'tree' | 'blob'; - oid: string; - format: 'content'; - object: Array<{ - mode: string; - path: string; - oid: string; - type?: 'blob' | 'tree'; - object?: GitTree; - }>; - } -} - -declare module 'isomorphic-git/src/models/GitAnnotatedTag.js' { - export class GitAnnotatedTag { - static from(buffer: Buffer): GitAnnotatedTag; - parse(): { - object: { - object: GitTree; - }; - type: string; - tag: string; - tagger: { - name: string; - email: string; - timestamp: number; - timezoneOffset: number; - }; - message: string; - signature?: string; - }; - } -} - -declare module 'isomorphic-git/src/models/GitCommit.js' { - export class GitCommit { - static from(buffer: Buffer): GitCommit; - parse(): { - tree: string; - parent: string[]; - author: { - name: string; - email: string; - timestamp: number; - timezoneOffset: number; - }; - committer: { - name: string; - email: string; - timestamp: number; - timezoneOffset: number; - }; - message: string; - gpgsig?: string; - }; - } -} - -declare module 'isomorphic-git/src/models/GitPackIndex.js' { - export class GitPackIndex { - static fromPack({ pack }: { pack: Buffer }): Promise; - read({ oid }: { oid: string }): Promise; - toBuffer(): Promise; - packfileSha: string; - hashes?: string[]; - offsets: Map; - readSlice({ start }: { start: number }): Promise<{ - type: - | 'blob' - | 'tree' - | 'commit' - | 'tag' - | 'ofs_delta' - | 'ref_delta'; - object?: Buffer | Uint8Array; - }>; - } -} - -declare module 'isomorphic-git/src/internal-apis.js' { - export function collect(data: any[]): Promise; -} - -declare module 'isomorphic-git/src/wire/parseUploadPackResponse.js' { - export function parseUploadPackResponse(data: Buffer): any; // Replace 'any' with a more specific type if known -} - -declare module 'isomorphic-git/src/errors/ObjectTypeError.js' { - export class ObjectTypeError extends Error { - constructor(message: string, expected: string, actual: string); - } -} diff --git a/packages/playground/website/playwright/e2e/website-ui.spec.ts b/packages/playground/website/playwright/e2e/website-ui.spec.ts index 039d83d1064..7f674143289 100644 --- a/packages/playground/website/playwright/e2e/website-ui.spec.ts +++ b/packages/playground/website/playwright/e2e/website-ui.spec.ts @@ -588,7 +588,10 @@ test.describe('Default Playground storage', () => { test('should create a browser-saved Playground by default', async ({ website, }) => { - await website.goto('./'); + await website.page.goto('./'); + await expect( + website.page.getByRole('button', { name: /Site Manager/ }) + ).toBeVisible(); await website.ensureSiteManagerIsClosed(); await expect From 153ff513f1da0827ef0703a6d9503988247d9ca4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Sun, 17 May 2026 23:18:31 +0200 Subject: [PATCH 03/47] Fix saved-by-default CI coverage --- .../website/playwright/e2e/blueprints.spec.ts | 37 ++++---- .../website/playwright/e2e/deployment.spec.ts | 1 + .../playwright/e2e/error-handling.spec.ts | 2 +- .../website/playwright/e2e/opfs.spec.ts | 91 ++++++++++++------- .../website/playwright/e2e/query-api.spec.ts | 1 + .../website/playwright/e2e/website-ui.spec.ts | 38 ++++++++ .../tests/test-legacy-wp-version-boot.mjs | 7 +- 7 files changed, 124 insertions(+), 53 deletions(-) diff --git a/packages/playground/website/playwright/e2e/blueprints.spec.ts b/packages/playground/website/playwright/e2e/blueprints.spec.ts index ee1696c40d5..a6ef14a7649 100644 --- a/packages/playground/website/playwright/e2e/blueprints.spec.ts +++ b/packages/playground/website/playwright/e2e/blueprints.spec.ts @@ -114,12 +114,13 @@ test('?blueprint-url=... should work with simple blueprints', async ({ browserName === 'webkit', 'This test is flaky in WebKit. It seems like a GitHub CI issue rather than an actual flakiness since it is reliable locally.' ); - await website.goto('/'); - const websiteUrl = page.url(); - const blueprintUrl = encodeURIComponent( - `${websiteUrl}test-fixtures/blueprint/blueprint-simple.json` + await website.goto('./?storage=temp'); + const websiteUrl = new URL( + 'test-fixtures/blueprint/blueprint-simple.json', + page.url() ); - await website.goto(`/?blueprint-url=${blueprintUrl}`); + const blueprintUrl = encodeURIComponent(websiteUrl.href); + await website.goto(`./?storage=temp&blueprint-url=${blueprintUrl}`); await expect(wordpress.locator('body')).toContainText( 'PREFACE TO PYGMALION' ); @@ -130,11 +131,11 @@ test('?blueprint-url=... should accept data URLs', async ({ website, wordpress, }) => { - await website.goto('/'); + await website.goto('./?storage=temp'); const blueprintUrl = encodeURIComponent( `data:application/json;base64,eyJsYW5kaW5nUGFnZSI6Ii9weWdtYWxpb24udHh0Iiwic3RlcHMiOlt7InN0ZXAiOiJ3cml0ZUZpbGUiLCJwYXRoIjoiL3dvcmRwcmVzcy9weWdtYWxpb24udHh0IiwiZGF0YSI6IlBSRUZBQ0UgVE8gUFlHTUFMSU9OIn1dfQ==` ); - await website.goto(`/?blueprint-url=${blueprintUrl}`); + await website.goto(`./?storage=temp&blueprint-url=${blueprintUrl}`); await expect(wordpress.locator('body')).toContainText( 'PREFACE TO PYGMALION' ); @@ -145,12 +146,13 @@ test('?blueprint-url=... should work with ZIP bundles', async ({ website, wordpress, }) => { - await website.goto('/'); - const websiteUrl = page.url(); - const blueprintUrl = encodeURIComponent( - `${websiteUrl}test-fixtures/blueprint/blueprint.zip` + await website.goto('./?storage=temp'); + const websiteUrl = new URL( + 'test-fixtures/blueprint/blueprint.zip', + page.url() ); - await website.goto(`/?blueprint-url=${blueprintUrl}`); + const blueprintUrl = encodeURIComponent(websiteUrl.href); + await website.goto(`./?storage=temp&blueprint-url=${blueprintUrl}`); await expect(wordpress.locator('body')).toContainText( 'PREFACE TO PYGMALION' ); @@ -161,12 +163,13 @@ test('?blueprint-url=... should work with JSON blueprints referring bundled reso website, wordpress, }) => { - await website.goto('/'); - const websiteUrl = page.url(); - const blueprintUrl = encodeURIComponent( - `${websiteUrl}test-fixtures/blueprint/blueprint-with-bundled-resources.json` + await website.goto('./?storage=temp'); + const websiteUrl = new URL( + 'test-fixtures/blueprint/blueprint-with-bundled-resources.json', + page.url() ); - await website.goto(`/?blueprint-url=${blueprintUrl}`); + const blueprintUrl = encodeURIComponent(websiteUrl.href); + await website.goto(`./?storage=temp&blueprint-url=${blueprintUrl}`); await expect(wordpress.locator('body')).toContainText( 'PREFACE TO PYGMALION' ); diff --git a/packages/playground/website/playwright/e2e/deployment.spec.ts b/packages/playground/website/playwright/e2e/deployment.spec.ts index aff7b4dc172..8349e4417d6 100644 --- a/packages/playground/website/playwright/e2e/deployment.spec.ts +++ b/packages/playground/website/playwright/e2e/deployment.spec.ts @@ -15,6 +15,7 @@ const url = new URL(`http://localhost:${port}`); // disable auto-login, the old Playground build encounters // a boot error. url.searchParams.set('login', 'no'); +url.searchParams.set('storage', 'temp'); // Specify the theme so we can assert against expected default content. // This theme is also what the reference screenshots are based on. url.searchParams.set('theme', 'twentytwentyfour'); diff --git a/packages/playground/website/playwright/e2e/error-handling.spec.ts b/packages/playground/website/playwright/e2e/error-handling.spec.ts index a490dc06a1d..b376f4233cc 100644 --- a/packages/playground/website/playwright/e2e/error-handling.spec.ts +++ b/packages/playground/website/playwright/e2e/error-handling.spec.ts @@ -44,7 +44,7 @@ test('should show download error modal when a resource download fails', async ({ // fetches the zip from downloads.wordpress.org via the CORS // proxy, triggering the resource-download-failed error through // the normal pipeline. - await page.goto('./?plugin=hello-dolly'); + await page.goto('./?storage=temp&plugin=hello-dolly'); const title = page.getByText('Could not download required files'); await expect(title).toBeVisible(); diff --git a/packages/playground/website/playwright/e2e/opfs.spec.ts b/packages/playground/website/playwright/e2e/opfs.spec.ts index ee187e372b1..c2f3e5587fc 100644 --- a/packages/playground/website/playwright/e2e/opfs.spec.ts +++ b/packages/playground/website/playwright/e2e/opfs.spec.ts @@ -77,12 +77,13 @@ test('should switch between sites', async ({ website, browserName }) => { `This test relies on OPFS which isn't available in Playwright's flavor of ${browserName}.` ); - await website.goto('./'); + await website.goto('./?storage=temp'); await website.ensureSiteManagerIsOpen(); // Save the temporary site using the modal - await saveSiteViaModal(website.page); + const firstSiteName = 'Switching Test Site'; + await saveSiteViaModal(website.page, { customName: firstSiteName }); await expect(website.page.getByLabel('Playground title')).not.toContainText( 'Unsaved Playground', @@ -91,20 +92,38 @@ test('should switch between sites', async ({ website, browserName }) => { timeout: 90000, } ); + await expect(website.page.getByLabel('Playground title')).toContainText( + firstSiteName + ); // Open the saved playgrounds overlay to switch sites await website.openSavedPlaygroundsOverlay(); - // Click on Temporary Playground in the overlay's site list + // The unsaved row creates a fresh default Playground. Since Playgrounds + // are saved by default, it should switch to a new saved site. await website.page .locator('[class*="siteRowContent"]') .filter({ hasText: 'Unsaved Playground' }) .click(); + await website.ensureSiteManagerIsOpen(); - // The overlay closes and site manager opens with the selected site - await expect(website.page.getByLabel('Playground title')).toContainText( + await expect(website.page.getByLabel('Playground title')).not.toContainText( + firstSiteName + ); + await expect(website.page.getByLabel('Playground title')).not.toContainText( 'Unsaved Playground' ); + + await website.openSavedPlaygroundsOverlay(); + await website.page + .locator('[class*="siteRowContent"]') + .filter({ hasText: firstSiteName }) + .click(); + await website.ensureSiteManagerIsOpen(); + + await expect(website.page.getByLabel('Playground title')).toContainText( + firstSiteName + ); }); test('should preserve PHP constants when saving a temporary site to OPFS', async ({ @@ -129,7 +148,7 @@ test('should preserve PHP constants when saving a temporary site to OPFS', async }, ], }; - await website.goto(`./#${JSON.stringify(blueprint)}`); + await website.goto(`./?storage=temp#${JSON.stringify(blueprint)}`); await website.ensureSiteManagerIsOpen(); @@ -153,7 +172,7 @@ test('should preserve PHP constants when saving a temporary site to OPFS', async // Open the saved playgrounds overlay to switch sites await website.openSavedPlaygroundsOverlay(); - // Switch to Temporary Playground + // Use the unsaved row to create another default Playground, then switch back. await website.page .locator('[class*="siteRowContent"]') .filter({ hasText: 'Unsaved Playground' }) @@ -180,7 +199,7 @@ test('should rename a saved Playground and persist after reload', async ({ `This test relies on OPFS which isn't available in Playwright's flavor of ${browserName}.` ); - await website.goto('./'); + await website.goto('./?storage=temp'); await website.ensureSiteManagerIsOpen(); // Save the temporary site to OPFS so rename is available @@ -238,7 +257,7 @@ test('should show save site modal with correct elements', async ({ `This test relies on OPFS which isn't available in Playwright's flavor of ${browserName}.` ); - await website.goto('./'); + await website.goto('./?storage=temp'); await website.ensureSiteManagerIsOpen(); // Click the Save button in the site manager panel @@ -282,7 +301,7 @@ test('should close save site modal without saving', async ({ `This test relies on OPFS which isn't available in Playwright's flavor of ${browserName}.` ); - await website.goto('./'); + await website.goto('./?storage=temp'); await website.ensureSiteManagerIsOpen(); // Open the modal @@ -328,7 +347,7 @@ test('should have playground name input text selected by default', async ({ `This test relies on OPFS which isn't available in Playwright's flavor of ${browserName}.` ); - await website.goto('./'); + await website.goto('./?storage=temp'); await website.ensureSiteManagerIsOpen(); // Open the modal @@ -363,7 +382,7 @@ test('should save site with custom name', async ({ website, browserName }) => { `This test relies on OPFS which isn't available in Playwright's flavor of ${browserName}.` ); - await website.goto('./'); + await website.goto('./?storage=temp'); await website.ensureSiteManagerIsOpen(); const customName = 'My Custom Playground Name'; @@ -396,7 +415,7 @@ test('should not persist save site modal through page refresh', async ({ `This test relies on OPFS which isn't available in Playwright's flavor of ${browserName}.` ); - await website.goto('./'); + await website.goto('./?storage=temp'); await website.ensureSiteManagerIsOpen(); // Open the save modal @@ -433,7 +452,7 @@ test('should display OPFS storage option as selected by default', async ({ `This test relies on OPFS which isn't available in Playwright's flavor of ${browserName}.` ); - await website.goto('./'); + await website.goto('./?storage=temp'); await website.ensureSiteManagerIsOpen(); // Open the save modal @@ -455,7 +474,7 @@ test('should display OPFS storage option as selected by default', async ({ await dialog.getByRole('button', { name: 'Cancel' }).click(); }); -test('should import ZIP into temporary site when a saved site exists', async ({ +test('should import ZIP into a new saved site when a saved site exists', async ({ website, wordpress, browserName, @@ -477,7 +496,7 @@ test('should import ZIP into temporary site when a saved site exists', async ({ }, ], }; - await website.goto(`./#${JSON.stringify(blueprint)}`); + await website.goto(`./?storage=temp#${JSON.stringify(blueprint)}`); // Verify the marker is present await expect(wordpress.locator('body')).toContainText(savedSiteMarker); @@ -518,12 +537,14 @@ test('should import ZIP into temporary site when a saved site exists', async ({ buffer: zipBuffer, }); - // The import should switch us to a temporary playground. - // Wait for the site title to show "Temporary Playground" - await expect(website.page.getByLabel('Playground title')).toContainText( - 'Unsaved Playground', + // The import should switch us to a new saved Playground by default. + await expect(website.page.getByLabel('Playground title')).not.toContainText( + savedSiteName, { timeout: 30000 } ); + await expect(website.page.getByLabel('Playground title')).not.toContainText( + 'Unsaved Playground' + ); // Now verify the saved site still has the original content. // Open the saved playgrounds overlay and switch to the saved site @@ -533,16 +554,17 @@ test('should import ZIP into temporary site when a saved site exists', async ({ .locator('[class*="siteRowContent"]') .filter({ hasText: savedSiteName }) .click(); + await website.ensureSiteManagerIsOpen(); // Wait for the saved site to load - this verifies the saved site wasn't overwritten - // by the ZIP import (which went to a temporary site instead) + // by the ZIP import (which went to a new saved site instead) await expect(website.page.getByLabel('Playground title')).toContainText( savedSiteName, { timeout: 30000 } ); }); -test('should create temporary site when importing ZIP while on a saved site with no existing temporary site', async ({ +test('should create a saved site when importing ZIP while on a saved site with no existing temporary site', async ({ website, wordpress, browserName, @@ -564,7 +586,7 @@ test('should create temporary site when importing ZIP while on a saved site with }, ], }; - await website.goto(`./#${JSON.stringify(blueprint)}`); + await website.goto(`./?storage=temp#${JSON.stringify(blueprint)}`); await expect(wordpress.locator('body')).toContainText(savedSiteMarker); await website.ensureSiteManagerIsOpen(); @@ -598,13 +620,13 @@ test('should create temporary site when importing ZIP while on a saved site with // Open the saved playgrounds overlay await website.openSavedPlaygroundsOverlay(); - // Verify there's no "Temporary Playground" in the list initially - // (the temporary site row should show but clicking it would create one) + // The unsaved row is available as the affordance for creating another + // Playground. const tempPlaygroundRow = website.page .locator('[class*="siteRowContent"]') .filter({ hasText: 'Unsaved Playground' }); - // The row exists but it's for creating a new temporary playground + // The row exists but creates a new saved Playground by default. await expect(tempPlaygroundRow).toBeVisible(); // Create a test ZIP @@ -628,12 +650,14 @@ test('should create temporary site when importing ZIP while on a saved site with buffer: zipBuffer, }); - // The import should trigger creation of a new temporary site. - // Wait for the site title to show "Temporary Playground" - await expect(website.page.getByLabel('Playground title')).toContainText( - 'Unsaved Playground', + // The import should trigger creation of a new saved site by default. + await expect(website.page.getByLabel('Playground title')).not.toContainText( + savedSiteName, { timeout: 30000 } ); + await expect(website.page.getByLabel('Playground title')).not.toContainText( + 'Unsaved Playground' + ); // Verify the saved site is still intact by switching to it await website.openSavedPlaygroundsOverlay(); @@ -642,9 +666,10 @@ test('should create temporary site when importing ZIP while on a saved site with .locator('[class*="siteRowContent"]') .filter({ hasText: savedSiteName }) .click(); + await website.ensureSiteManagerIsOpen(); // Wait for the saved site to load - this verifies the saved site wasn't overwritten - // by the ZIP import (which went to a temporary site instead) + // by the ZIP import (which went to a new saved site instead) await expect(website.page.getByLabel('Playground title')).toContainText( savedSiteName, { timeout: 30000 } @@ -672,7 +697,7 @@ test.describe('Missing site modal', () => { // Use a unique slug that definitely doesn't exist const uniqueSlug = `missing-modal-test-${Date.now()}`; - await website.goto(`./?site-slug=${uniqueSlug}`); + await website.goto(`./?site-slug=${uniqueSlug}&storage=temp`); // The modal should appear early, even before WordPress fully loads await expect( @@ -697,7 +722,7 @@ test.describe('Missing site modal', () => { await context.clearCookies(); const uniqueSlug = `dismiss-modal-test-${Date.now()}`; - await website.goto(`./?site-slug=${uniqueSlug}`); + await website.goto(`./?site-slug=${uniqueSlug}&storage=temp`); // Wait for modal const dialog = website.page.getByRole('dialog', { diff --git a/packages/playground/website/playwright/e2e/query-api.spec.ts b/packages/playground/website/playwright/e2e/query-api.spec.ts index 56a574889cb..330c88445c6 100644 --- a/packages/playground/website/playwright/e2e/query-api.spec.ts +++ b/packages/playground/website/playwright/e2e/query-api.spec.ts @@ -398,6 +398,7 @@ async function gotoPHPOnlyPlayground( ) { const query = new URLSearchParams({ php: '8.3', + storage: 'temp', ...queryParams, }); const blueprint: Blueprint = { diff --git a/packages/playground/website/playwright/e2e/website-ui.spec.ts b/packages/playground/website/playwright/e2e/website-ui.spec.ts index 7f674143289..a449f241260 100644 --- a/packages/playground/website/playwright/e2e/website-ui.spec.ts +++ b/packages/playground/website/playwright/e2e/website-ui.spec.ts @@ -587,7 +587,13 @@ test.describe('Database panel', () => { test.describe('Default Playground storage', () => { test('should create a browser-saved Playground by default', async ({ website, + browserName, }) => { + test.skip( + browserName !== 'chromium', + `Saved-by-default Playgrounds rely on OPFS, which is not available in Playwright's ${browserName}.` + ); + await website.page.goto('./'); await expect( website.page.getByRole('button', { name: /Site Manager/ }) @@ -606,7 +612,13 @@ test.describe('Default Playground storage', () => { test('should show browser storage details in the Site Manager by default', async ({ website, + browserName, }) => { + test.skip( + browserName !== 'chromium', + `Saved-by-default Playgrounds rely on OPFS, which is not available in Playwright's ${browserName}.` + ); + await website.goto('./'); await website.ensureSiteManagerIsOpen(); @@ -622,7 +634,13 @@ test.describe('Default Playground storage', () => { test('should persist WordPress changes after refreshing the default Playground', async ({ website, + browserName, }) => { + test.skip( + browserName !== 'chromium', + `Saved-by-default Playgrounds rely on OPFS, which is not available in Playwright's ${browserName}.` + ); + await website.goto('./'); await expect .poll(() => @@ -664,6 +682,26 @@ echo get_option('blogname'); expect(blogName).toBe(expectedBlogName); }); + test('should fall back to an unsaved Playground when browser storage is unavailable', async ({ + website, + browserName, + }) => { + test.skip( + browserName === 'chromium', + 'Chromium provides OPFS in Playwright, so the saved-by-default tests cover it.' + ); + + await website.goto('./'); + await website.ensureSiteManagerIsClosed(); + + expect(new URL(website.page.url()).searchParams.get('site-slug')).toBe( + null + ); + await expect( + website.page.getByText('Unsaved Playground') + ).toBeVisible(); + }); + test('should show "Unsaved Playground" status for storage=temp Playgrounds', async ({ website, }) => { diff --git a/packages/playground/wordpress/tests/test-legacy-wp-version-boot.mjs b/packages/playground/wordpress/tests/test-legacy-wp-version-boot.mjs index 0fdbb1c3f81..55fd8449f06 100644 --- a/packages/playground/wordpress/tests/test-legacy-wp-version-boot.mjs +++ b/packages/playground/wordpress/tests/test-legacy-wp-version-boot.mjs @@ -365,13 +365,16 @@ function shouldRetryFrontPageBoot(consoleErrors) { ); } -const browser = await chromium.launch({ headless: true }); +const browser = await chromium.launch({ + headless: true, + executablePath: process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH, +}); for (const { wp, php } of MATRIX) { const label = `WP ${wp} (PHP ${php})`; process.stdout.write(`${label}... `); - const url = `http://127.0.0.1:${PORT}/website-server/?php=${php}&wp=${wp}`; + const url = `http://127.0.0.1:${PORT}/website-server/?php=${php}&wp=${wp}&storage=temp`; // Isolate every version in a fresh browser context so that OPFS // (where Playground persists site state), IndexedDB, localStorage From c1a4efcdd23d6a7d4279287e0bd712c46f8f8bfb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Sun, 17 May 2026 23:59:47 +0200 Subject: [PATCH 04/47] Fix sites API saved-default expectation --- .../playground/website/playwright/e2e/sites-api.spec.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/packages/playground/website/playwright/e2e/sites-api.spec.ts b/packages/playground/website/playwright/e2e/sites-api.spec.ts index d28a15b6a77..4ef720c89bf 100644 --- a/packages/playground/website/playwright/e2e/sites-api.spec.ts +++ b/packages/playground/website/playwright/e2e/sites-api.spec.ts @@ -7,7 +7,10 @@ test('window.playgroundSites is exposed after boot', async ({ website }) => { ); }); -test('playgroundSites.list() returns the active site', async ({ website }) => { +test('playgroundSites.list() returns the active site', async ({ + website, + browserName, +}) => { await website.goto('./'); await website.page.waitForFunction(() => Boolean((window as any).playgroundSites?.getClient()) @@ -20,7 +23,9 @@ test('playgroundSites.list() returns the active site', async ({ website }) => { const active = sites.find((s: any) => s.isActive); expect(active).toBeTruthy(); expect(active.slug).toBeTruthy(); - expect(active.storage).toBe('temporary'); + expect(active.storage).toBe( + browserName === 'chromium' ? 'opfs' : 'temporary' + ); }); test('playgroundSites.saveInBrowser() persists a temporary site', async ({ From 17796f977a4836c0ec91b2ba18f95928ddd8fcb5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Mon, 18 May 2026 00:44:41 +0200 Subject: [PATCH 05/47] Use temp storage for unrelated UI specs --- .../playwright/e2e/client-side-media.spec.ts | 8 ++++-- .../e2e/shutdown-loopback-prefetch.spec.ts | 7 +++-- .../website/playwright/e2e/sites-api.spec.ts | 3 +- .../website/playwright/e2e/website-ui.spec.ts | 28 +++++++++---------- 4 files changed, 25 insertions(+), 21 deletions(-) diff --git a/packages/playground/website/playwright/e2e/client-side-media.spec.ts b/packages/playground/website/playwright/e2e/client-side-media.spec.ts index 7707da67447..5410e9a3797 100644 --- a/packages/playground/website/playwright/e2e/client-side-media.spec.ts +++ b/packages/playground/website/playwright/e2e/client-side-media.spec.ts @@ -68,7 +68,9 @@ test('Post editor should be cross-origin isolated with SharedArrayBuffer availab website, wordpress, }) => { - await website.goto(`./#${JSON.stringify(clientSideMediaBlueprint)}`); + await website.goto( + `./?storage=temp#${JSON.stringify(clientSideMediaBlueprint)}` + ); // Wait for the block editor to fully load. The editor header is visible in both // fullscreen and non-fullscreen modes. @@ -96,7 +98,9 @@ test('Gutenberg should report client-side media processing as enabled', async ({ website, wordpress, }) => { - await website.goto(`./#${JSON.stringify(clientSideMediaBlueprint)}`); + await website.goto( + `./?storage=temp#${JSON.stringify(clientSideMediaBlueprint)}` + ); await expect( wordpress.locator('.edit-post-header, .editor-header') diff --git a/packages/playground/website/playwright/e2e/shutdown-loopback-prefetch.spec.ts b/packages/playground/website/playwright/e2e/shutdown-loopback-prefetch.spec.ts index 5220e3eb76a..3eadf4f74aa 100644 --- a/packages/playground/website/playwright/e2e/shutdown-loopback-prefetch.spec.ts +++ b/packages/playground/website/playwright/e2e/shutdown-loopback-prefetch.spec.ts @@ -72,7 +72,7 @@ add_action( 'shutdown', function() { // Navigate without waiting for nested iframes. We only need the boot // process to run prefetchUpdateChecks(); we don't need wp-admin to render. await website.page.goto( - `./?networking=yes&url=/wp-admin/#${JSON.stringify(blueprint)}` + `./?storage=temp&networking=yes&url=/wp-admin/#${JSON.stringify(blueprint)}` ); // Wait for the playground client to be available (set before prefetch runs). @@ -114,7 +114,8 @@ echo (string) get_option('${optName}', ''); expect(parsed.kind).toBe('wp_error'); if (parsed.kind === 'wp_error') { expect(parsed.code).toBe('http_request_block'); - expect(parsed.message).toBe('Loopback requests are not to be pre-fetched'); + expect(parsed.message).toBe( + 'Loopback requests are not to be pre-fetched' + ); } }); - diff --git a/packages/playground/website/playwright/e2e/sites-api.spec.ts b/packages/playground/website/playwright/e2e/sites-api.spec.ts index 4ef720c89bf..fadc320e30d 100644 --- a/packages/playground/website/playwright/e2e/sites-api.spec.ts +++ b/packages/playground/website/playwright/e2e/sites-api.spec.ts @@ -37,7 +37,7 @@ test('playgroundSites.saveInBrowser() persists a temporary site', async ({ `This test relies on OPFS which isn't available in Playwright's flavor of ${browserName}.` ); - await website.goto('./'); + await website.goto('./?storage=temp'); await website.page.waitForFunction(() => Boolean((window as any).playgroundSites?.getClient()) ); @@ -65,7 +65,6 @@ test('playgroundSites.rename() renames a saved site', async ({ const newName = await website.page.evaluate(async () => { const api = (window as any).playgroundSites; - await api.saveInBrowser(); const name = 'Renamed Via API'; await api.rename(name); const sites = api.list(); diff --git a/packages/playground/website/playwright/e2e/website-ui.spec.ts b/packages/playground/website/playwright/e2e/website-ui.spec.ts index a449f241260..ed80e72fada 100644 --- a/packages/playground/website/playwright/e2e/website-ui.spec.ts +++ b/packages/playground/website/playwright/e2e/website-ui.spec.ts @@ -12,7 +12,7 @@ import * as MinifiedWordPressVersions from '../../../wordpress-builds/src/wordpr test('should reflect the URL update from the navigation bar in the WordPress site', async ({ website, }) => { - await website.goto('./?url=/wp-admin/'); + await website.goto('./?storage=temp&url=/wp-admin/'); await website.ensureSiteManagerIsClosed(); await expect(website.page.locator('input[value="/wp-admin/"]')).toHaveValue( '/wp-admin/' @@ -27,7 +27,7 @@ test('should correctly load /wp-admin without the trailing slash', async ({ browserName === 'webkit', 'This test is flaky in WebKit. It seems like a GitHub CI issue rather than an actual flakiness since it is reliable locally.' ); - await website.goto('./?url=/wp-admin'); + await website.goto('./?storage=temp&url=/wp-admin'); await website.ensureSiteManagerIsClosed(); await expect(website.page.locator('input[value="/wp-admin/"]')).toHaveValue( '/wp-admin/' @@ -36,7 +36,7 @@ test('should correctly load /wp-admin without the trailing slash', async ({ SupportedPHPVersions.forEach(async (version) => { test(`should switch PHP version to ${version}`, async ({ website }) => { - await website.goto(`./`); + await website.goto('./?storage=temp'); await website.ensureSiteManagerIsOpen(); await website.page.getByLabel('PHP version').selectOption(version); await website.page @@ -58,7 +58,7 @@ Object.keys(MinifiedWordPressVersions) test(`should switch WordPress version to ${version}`, async ({ website, }) => { - await website.goto('./'); + await website.goto('./?storage=temp'); await website.ensureSiteManagerIsOpen(); await website.page .getByLabel('WordPress version') @@ -76,7 +76,7 @@ Object.keys(MinifiedWordPressVersions) }); test('should display networking as active by default', async ({ website }) => { - await website.goto('./'); + await website.goto('./?storage=temp'); await website.ensureSiteManagerIsOpen(); await expect(website.page.getByLabel('Network access')).toBeChecked(); }); @@ -84,13 +84,13 @@ test('should display networking as active by default', async ({ website }) => { test('should display networking as active when networking is enabled', async ({ website, }) => { - await website.goto('./?networking=yes'); + await website.goto('./?storage=temp&networking=yes'); await website.ensureSiteManagerIsOpen(); await expect(website.page.getByLabel('Network access')).toBeChecked(); }); test('should enable networking when requested', async ({ website }) => { - await website.goto('./'); + await website.goto('./?storage=temp'); await website.ensureSiteManagerIsOpen(); await website.page.getByLabel('Network access').check(); @@ -102,7 +102,7 @@ test('should enable networking when requested', async ({ website }) => { }); test('should disable networking when requested', async ({ website }) => { - await website.goto('./?networking=yes'); + await website.goto('./?storage=temp&networking=yes'); await website.ensureSiteManagerIsOpen(); await website.page.getByLabel('Network access').uncheck(); @@ -128,7 +128,7 @@ test('should display PHP output even when a fatal error is hit', async ({ }, ], }; - await website.goto(`./#${JSON.stringify(blueprint)}`); + await website.goto(`./?storage=temp#${JSON.stringify(blueprint)}`); await expect(wordpress.locator('body')).toContainText( 'This is a fatal error' @@ -163,7 +163,7 @@ test('should edit a file in the code editor and see changes in the viewport', as website, wordpress, }) => { - await website.goto('./'); + await website.goto('./?storage=temp'); // Open site manager await website.ensureSiteManagerIsOpen(); @@ -243,7 +243,7 @@ test('should edit a blueprint in the blueprint editor and recreate the playgroun website, wordpress, }) => { - await website.goto('./'); + await website.goto('./?storage=temp'); // Open site manager await website.ensureSiteManagerIsOpen(); @@ -331,7 +331,7 @@ test('should copy blueprint link to clipboard when share button is clicked', asy // Grant clipboard permissions await context.grantPermissions(['clipboard-read', 'clipboard-write']); - await website.goto('./'); + await website.goto('./?storage=temp'); // Open site manager await website.ensureSiteManagerIsOpen(); @@ -382,7 +382,7 @@ test('should copy blueprint link to clipboard when share button is clicked', asy test.describe('Database panel', () => { test.beforeEach(async ({ website }) => { - await website.goto('./'); + await website.goto('./?storage=temp'); await website.ensureSiteManagerIsOpen(); // Navigate to Database tab @@ -756,7 +756,7 @@ echo get_option('blogname'); test('should not include Google Analytics when VITE_GOOGLE_ANALYTICS_ID is not set', async ({ website, }) => { - await website.goto('./'); + await website.goto('./?storage=temp'); const gtmScripts = await website.page .locator('script[src*="googletagmanager.com"]') .count(); From 4551c4b2e726fddcdcdb4bcd4614e0cd9f7b585c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Mon, 18 May 2026 00:58:42 +0200 Subject: [PATCH 06/47] Preserve version params for saved sites --- .../website/playwright/e2e/website-ui.spec.ts | 5 +++-- .../website/src/lib/state/url/router.spec.ts | 18 ++++++++++++++++++ .../website/src/lib/state/url/router.ts | 4 ++++ 3 files changed, 25 insertions(+), 2 deletions(-) diff --git a/packages/playground/website/playwright/e2e/website-ui.spec.ts b/packages/playground/website/playwright/e2e/website-ui.spec.ts index ed80e72fada..54f81c3c567 100644 --- a/packages/playground/website/playwright/e2e/website-ui.spec.ts +++ b/packages/playground/website/playwright/e2e/website-ui.spec.ts @@ -377,7 +377,8 @@ test('should copy blueprint link to clipboard when share button is clicked', asy Uint8Array.from(atob(base64Part), (c) => c.charCodeAt(0)) ) ); - expect(decodedBlueprint).toHaveProperty('landingPage'); + expect(decodedBlueprint).toHaveProperty('steps'); + expect(Array.isArray(decodedBlueprint.steps)).toBe(true); }); test.describe('Database panel', () => { @@ -573,7 +574,7 @@ test.describe('Database panel', () => { .getByRole('link', { name: 'SQL' }) .click(); await newPage.waitForLoadState(); - await newPage.locator('.CodeMirror').click(); + await newPage.locator('.CodeMirror.cm-s-default').click(); await newPage.keyboard.type('SHOW TABLES'); await newPage.getByRole('button', { name: 'Go' }).click(); await newPage.waitForLoadState(); diff --git a/packages/playground/website/src/lib/state/url/router.spec.ts b/packages/playground/website/src/lib/state/url/router.spec.ts index 07c54c7f07b..898fcf1342e 100644 --- a/packages/playground/website/src/lib/state/url/router.spec.ts +++ b/packages/playground/website/src/lib/state/url/router.spec.ts @@ -4,6 +4,7 @@ import { PlaygroundRoute, } from './router'; import { decodeBlueprintHash } from './decode-blueprint-hash'; +import type { SiteInfo } from '../redux/slice-sites'; const toBase64 = (s: string) => typeof btoa === 'function' @@ -124,6 +125,23 @@ describe('PlaygroundRoute site creation routes', () => { expect(new URL(url).searchParams.get('storage')).toBeNull(); }); + it('preserves Query API version settings when selecting a saved site', () => { + const url = PlaygroundRoute.site( + { + slug: 'saved-site', + metadata: { + storage: 'opfs', + }, + } as unknown as SiteInfo, + 'https://playground.test/website-server/?url=/wp-admin/&php=8.0&wp=6.6' + ); + const params = new URL(url).searchParams; + expect(params.get('site-slug')).toBe('saved-site'); + expect(params.get('url')).toBe('/wp-admin/'); + expect(params.get('php')).toBe('8.0'); + expect(params.get('wp')).toBe('6.6'); + }); + it('detects explicit temporary storage requests', () => { expect( isTemporaryStorageRequested( diff --git a/packages/playground/website/src/lib/state/url/router.ts b/packages/playground/website/src/lib/state/url/router.ts index 370d5d9c14e..f494a6ea313 100644 --- a/packages/playground/website/src/lib/state/url/router.ts +++ b/packages/playground/website/src/lib/state/url/router.ts @@ -89,6 +89,10 @@ export class PlaygroundRoute { 'mode', 'networking', 'login', + 'php', + 'wp', + 'language', + 'multisite', 'url', 'page-title', 'mcp', From 6f12383f1b3d179ea5e2bb5a8ba541a3aa6a12f8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Mon, 18 May 2026 01:11:49 +0200 Subject: [PATCH 07/47] Assert preserved query params by value --- .../website/playwright/e2e/website-ui.spec.ts | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/packages/playground/website/playwright/e2e/website-ui.spec.ts b/packages/playground/website/playwright/e2e/website-ui.spec.ts index 54f81c3c567..06684eced04 100644 --- a/packages/playground/website/playwright/e2e/website-ui.spec.ts +++ b/packages/playground/website/playwright/e2e/website-ui.spec.ts @@ -141,7 +141,10 @@ test('should keep query arguments when updating settings', async ({ }) => { await website.goto('./?url=/wp-admin/&php=8.0&wp=6.6'); - expect(website.page.url()).toContain('?url=%2Fwp-admin%2F&php=8.0&wp=6.6'); + const initialParams = new URL(website.page.url()).searchParams; + expect(initialParams.get('url')).toBe('/wp-admin/'); + expect(initialParams.get('php')).toBe('8.0'); + expect(initialParams.get('wp')).toBe('6.6'); expect( await wordpress.locator('body').evaluate((body) => body.baseURI) ).toMatch('/wp-admin/'); @@ -151,9 +154,11 @@ test('should keep query arguments when updating settings', async ({ await website.page.getByText('Apply Settings & Reset Playground').click(); await website.waitForNestedIframes(); - expect(website.page.url()).toMatch( - '?url=%2Fwp-admin%2F&php=8.0&wp=6.6&networking=yes' - ); + const updatedParams = new URL(website.page.url()).searchParams; + expect(updatedParams.get('url')).toBe('/wp-admin/'); + expect(updatedParams.get('php')).toBe('8.0'); + expect(updatedParams.get('wp')).toBe('6.6'); + expect(updatedParams.get('networking')).toBe('yes'); expect( await wordpress.locator('body').evaluate((body) => body.baseURI) ).toMatch('/wp-admin/'); From 5341de3c43947af943d8a193b07a3a331b70c166 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Mon, 18 May 2026 01:41:22 +0200 Subject: [PATCH 08/47] Make saved-default UI specs deterministic --- .../website/playwright/e2e/website-ui.spec.ts | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/packages/playground/website/playwright/e2e/website-ui.spec.ts b/packages/playground/website/playwright/e2e/website-ui.spec.ts index 06684eced04..f18761acf6a 100644 --- a/packages/playground/website/playwright/e2e/website-ui.spec.ts +++ b/packages/playground/website/playwright/e2e/website-ui.spec.ts @@ -139,12 +139,16 @@ test('should keep query arguments when updating settings', async ({ website, wordpress, }) => { - await website.goto('./?url=/wp-admin/&php=8.0&wp=6.6'); + await website.goto( + './?storage=temp&url=/wp-admin/&php=8.0&wp=6.6&networking=no' + ); const initialParams = new URL(website.page.url()).searchParams; + expect(initialParams.get('storage')).toBe('temp'); expect(initialParams.get('url')).toBe('/wp-admin/'); expect(initialParams.get('php')).toBe('8.0'); expect(initialParams.get('wp')).toBe('6.6'); + expect(initialParams.get('networking')).toBe('no'); expect( await wordpress.locator('body').evaluate((body) => body.baseURI) ).toMatch('/wp-admin/'); @@ -155,6 +159,7 @@ test('should keep query arguments when updating settings', async ({ await website.waitForNestedIframes(); const updatedParams = new URL(website.page.url()).searchParams; + expect(updatedParams.get('storage')).toBe('temp'); expect(updatedParams.get('url')).toBe('/wp-admin/'); expect(updatedParams.get('php')).toBe('8.0'); expect(updatedParams.get('wp')).toBe('6.6'); @@ -690,12 +695,13 @@ echo get_option('blogname'); test('should fall back to an unsaved Playground when browser storage is unavailable', async ({ website, - browserName, }) => { - test.skip( - browserName === 'chromium', - 'Chromium provides OPFS in Playwright, so the saved-by-default tests cover it.' - ); + await website.page.addInitScript(() => { + Object.defineProperty(navigator.storage, 'getDirectory', { + value: undefined, + configurable: true, + }); + }); await website.goto('./'); await website.ensureSiteManagerIsClosed(); From 41b4f33ed4f5abaee1b6640310df34b316ca64d6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Mon, 18 May 2026 01:57:49 +0200 Subject: [PATCH 09/47] Align sites API default storage expectation --- .../playground/website/playwright/e2e/sites-api.spec.ts | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/packages/playground/website/playwright/e2e/sites-api.spec.ts b/packages/playground/website/playwright/e2e/sites-api.spec.ts index fadc320e30d..e066171dabb 100644 --- a/packages/playground/website/playwright/e2e/sites-api.spec.ts +++ b/packages/playground/website/playwright/e2e/sites-api.spec.ts @@ -7,10 +7,7 @@ test('window.playgroundSites is exposed after boot', async ({ website }) => { ); }); -test('playgroundSites.list() returns the active site', async ({ - website, - browserName, -}) => { +test('playgroundSites.list() returns the active site', async ({ website }) => { await website.goto('./'); await website.page.waitForFunction(() => Boolean((window as any).playgroundSites?.getClient()) @@ -24,7 +21,9 @@ test('playgroundSites.list() returns the active site', async ({ expect(active).toBeTruthy(); expect(active.slug).toBeTruthy(); expect(active.storage).toBe( - browserName === 'chromium' ? 'opfs' : 'temporary' + new URL(website.page.url()).searchParams.has('site-slug') + ? 'opfs' + : 'temporary' ); }); From 760c89479792d116aac8e4068fe6bfafb284088c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Mon, 18 May 2026 02:22:56 +0200 Subject: [PATCH 10/47] Use temporary storage in query API specs --- .../website/playwright/e2e/query-api.spec.ts | 30 +++++++++++-------- 1 file changed, 18 insertions(+), 12 deletions(-) diff --git a/packages/playground/website/playwright/e2e/query-api.spec.ts b/packages/playground/website/playwright/e2e/query-api.spec.ts index 330c88445c6..3d3a9dbd5a4 100644 --- a/packages/playground/website/playwright/e2e/query-api.spec.ts +++ b/packages/playground/website/playwright/e2e/query-api.spec.ts @@ -17,7 +17,7 @@ const LatestSupportedWordPressVersion = Object.keys( test('should load PHP 8.3 by default', async ({ website, wordpress }) => { // Navigate to the website - await website.goto('./?url=/phpinfo.php'); + await website.goto('./?storage=temp&url=/phpinfo.php'); await expect(wordpress.locator('h1.p').first()).toContainText( 'PHP Version 8.3' ); @@ -157,7 +157,7 @@ test('should load WordPress latest by default', async ({ website, wordpress, }) => { - await website.goto('./?url=/wp-admin/'); + await website.goto('./?storage=temp&url=/wp-admin/'); const expectedBodyClass = 'branch-' + LatestSupportedWordPressVersion.replace('.', '-'); @@ -170,7 +170,7 @@ test('should load WordPress 6.3 when requested', async ({ website, wordpress, }) => { - await website.goto('./?wp=6.3&url=/wp-admin/'); + await website.goto('./?storage=temp&wp=6.3&url=/wp-admin/'); await expect(wordpress.locator(`body.branch-6-3`)).toContainText( 'Dashboard' ); @@ -180,7 +180,9 @@ test('should disable networking when requested', async ({ website, wordpress, }) => { - await website.goto('./?networking=no&url=/wp-admin/plugin-install.php'); + await website.goto( + './?storage=temp&networking=no&url=/wp-admin/plugin-install.php' + ); await expect(wordpress.locator('.notice.error')).toContainText( 'Network access is an experimental, opt-in feature' ); @@ -190,12 +192,16 @@ test('should enable networking when requested', async ({ website, wordpress, }) => { - await website.goto('./?networking=yes&url=/wp-admin/plugin-install.php'); + await website.goto( + './?storage=temp&networking=yes&url=/wp-admin/plugin-install.php' + ); await expect(wordpress.locator('body')).toContainText('Install Now'); }); test('should install the specified plugin', async ({ website, wordpress }) => { - await website.goto('./?plugin=gutenberg&url=/wp-admin/plugins.php'); + await website.goto( + './?storage=temp&plugin=gutenberg&url=/wp-admin/plugins.php' + ); await expect(wordpress.locator('#deactivate-gutenberg')).toContainText( 'Deactivate' ); @@ -205,7 +211,7 @@ test('should login the user in by default if no login query parameter is provide website, wordpress, }) => { - await website.goto('./?url=/wp-admin/'); + await website.goto('./?storage=temp&url=/wp-admin/'); await expect(wordpress.locator('body')).toContainText('Dashboard'); }); @@ -213,7 +219,7 @@ test('should login the user in if the login query parameter is set to yes', asyn website, wordpress, }) => { - await website.goto('./?login=yes&url=/wp-admin/'); + await website.goto('./?storage=temp&login=yes&url=/wp-admin/'); await expect(wordpress.locator('body')).toContainText('Dashboard'); }); @@ -221,7 +227,7 @@ test('should not login the user in if the login query parameter is set to no', a website, wordpress, }) => { - await website.goto('./?login=no&url=/wp-admin/'); + await website.goto('./?storage=temp&login=no&url=/wp-admin/'); await expect(wordpress.locator('input[type="submit"]')).toContainText( 'Log In' ); @@ -232,7 +238,7 @@ test('should not login the user in if the login query parameter is set to no', a ['/wp-admin/post.php?post=1&action=edit', 'should redirect to post editor'], ].forEach(([path, description]) => { test(description, async ({ website, wordpress }) => { - await website.goto(`./?url=${encodeURIComponent(path)}`); + await website.goto(`./?storage=temp&url=${encodeURIComponent(path)}`); expect( await wordpress .locator('body') @@ -251,7 +257,7 @@ test('should translate WP-admin to Spanish using the language query parameter', `It's unclear why this test fails on Safari. The root cause of the failure is unknown as the feature ` + `seems to be working in manual testing.` ); - await website.goto('./?language=es_ES&url=/wp-admin/'); + await website.goto('./?storage=temp&language=es_ES&url=/wp-admin/'); await expect(wordpress.locator('body')).toContainText('Escritorio'); }); @@ -318,7 +324,7 @@ test('should retain encoded control characters in the URL', async ({ // most wp-admin pages enforce a redirect to a sanitized (broken) // version of the URL. await website.goto( - `./?url=${encodeURIComponent( + `./?storage=temp&url=${encodeURIComponent( path )}&plugin=html-api-debugger#${JSON.stringify(blueprint)}` ); From 9e3bb6c7198d5c63ce67c994f07530761861e2cc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Mon, 18 May 2026 13:45:45 +0200 Subject: [PATCH 11/47] Restore temporary overlay start and background saved sync --- .../website/playwright/e2e/opfs.spec.ts | 8 +- .../website/playwright/e2e/website-ui.spec.ts | 26 ++++++ .../saved-playgrounds-overlay/index.tsx | 4 +- .../src/lib/state/redux/boot-site-client.ts | 87 ++++++++++++++++--- 4 files changed, 110 insertions(+), 15 deletions(-) diff --git a/packages/playground/website/playwright/e2e/opfs.spec.ts b/packages/playground/website/playwright/e2e/opfs.spec.ts index c2f3e5587fc..9206dce86f1 100644 --- a/packages/playground/website/playwright/e2e/opfs.spec.ts +++ b/packages/playground/website/playwright/e2e/opfs.spec.ts @@ -99,8 +99,7 @@ test('should switch between sites', async ({ website, browserName }) => { // Open the saved playgrounds overlay to switch sites await website.openSavedPlaygroundsOverlay(); - // The unsaved row creates a fresh default Playground. Since Playgrounds - // are saved by default, it should switch to a new saved site. + // The unsaved row is an explicit opt-out from saved-by-default. await website.page .locator('[class*="siteRowContent"]') .filter({ hasText: 'Unsaved Playground' }) @@ -110,9 +109,12 @@ test('should switch between sites', async ({ website, browserName }) => { await expect(website.page.getByLabel('Playground title')).not.toContainText( firstSiteName ); - await expect(website.page.getByLabel('Playground title')).not.toContainText( + await expect(website.page.getByLabel('Playground title')).toContainText( 'Unsaved Playground' ); + expect(new URL(website.page.url()).searchParams.get('storage')).toBe( + 'temp' + ); await website.openSavedPlaygroundsOverlay(); await website.page diff --git a/packages/playground/website/playwright/e2e/website-ui.spec.ts b/packages/playground/website/playwright/e2e/website-ui.spec.ts index f18761acf6a..d5df6d18264 100644 --- a/packages/playground/website/playwright/e2e/website-ui.spec.ts +++ b/packages/playground/website/playwright/e2e/website-ui.spec.ts @@ -621,6 +621,32 @@ test.describe('Default Playground storage', () => { ); }); + test('should start an unsaved Playground from the overlay opt-out', async ({ + website, + browserName, + }) => { + test.skip( + browserName !== 'chromium', + `Saved-by-default Playgrounds rely on OPFS, which is not available in Playwright's ${browserName}.` + ); + + await website.goto('./'); + await website.openSavedPlaygroundsOverlay(); + await website.page + .locator('[class*="siteRowContent"]') + .filter({ hasText: 'Unsaved Playground' }) + .click(); + await website.waitForNestedIframes(); + await website.ensureSiteManagerIsClosed(); + + const url = new URL(website.page.url()); + expect(url.searchParams.get('storage')).toBe('temp'); + expect(url.searchParams.get('site-slug')).toBe(null); + await expect( + website.page.getByText('Unsaved Playground') + ).toBeVisible(); + }); + test('should show browser storage details in the Site Manager by default', async ({ website, browserName, diff --git a/packages/playground/website/src/components/saved-playgrounds-overlay/index.tsx b/packages/playground/website/src/components/saved-playgrounds-overlay/index.tsx index e4c7ef31b24..3764d5741ff 100644 --- a/packages/playground/website/src/components/saved-playgrounds-overlay/index.tsx +++ b/packages/playground/website/src/components/saved-playgrounds-overlay/index.tsx @@ -233,7 +233,9 @@ export function SavedPlaygroundsOverlay({ dispatch(setSiteManagerSection('site-details')); onClose(); } else { - createVanillaSite(); + dispatch(setSiteManagerOpen(false)); + redirectTo(PlaygroundRoute.newTemporarySite()); + onClose(); } }; diff --git a/packages/playground/website/src/lib/state/redux/boot-site-client.ts b/packages/playground/website/src/lib/state/redux/boot-site-client.ts index 2b2b643dd66..abf9d8bc532 100644 --- a/packages/playground/website/src/lib/state/redux/boot-site-client.ts +++ b/packages/playground/website/src/lib/state/redux/boot-site-client.ts @@ -17,7 +17,7 @@ import { isBlueprintBundle, } from '@wp-playground/blueprints'; import { logger } from '@php-wasm/logger'; -import { setupPostMessageRelay } from '@php-wasm/web'; +import { type SyncProgress, setupPostMessageRelay } from '@php-wasm/web'; import { startPlaygroundWeb } from '@wp-playground/client'; import type { PlaygroundClient } from '@wp-playground/remote'; import { getRemoteUrl } from '../../config'; @@ -157,6 +157,20 @@ export function bootSiteClient( !isBlueprintBundle(blueprint) && blueprint.preferredVersions?.wp === false; + const shouldSyncNewOpfsSiteInBackground = + site.metadata.storage === 'opfs' && + !!mountDescriptor && + !isWordPressInstalled; + const mounts = + mountDescriptor && !shouldSyncNewOpfsSiteInBackground + ? [ + { + ...mountDescriptor, + initialSyncDirection: 'opfs-to-memfs' as const, + }, + ] + : []; + let playground: PlaygroundClient | undefined = undefined; try { const phpExtensions = phpExtensionQueryArgsToExtensionsArray( @@ -183,14 +197,7 @@ export function bootSiteClient( }, // Log Blueprint events onBlueprintValidated: logBlueprintEvents, - mounts: mountDescriptor - ? [ - { - ...mountDescriptor, - initialSyncDirection: 'opfs-to-memfs', - }, - ] - : [], + mounts, shouldInstallWordPress: blueprintRequestedNoWordPress ? false : !isWordPressInstalled, @@ -291,6 +298,7 @@ export function bootSiteClient( if (signal.aborted || !playground) { return; } + const connectedPlayground = playground as PlaygroundClient; setupPostMessageRelay(iframe, document.location.origin); @@ -298,12 +306,69 @@ export function bootSiteClient( addClientInfo({ siteSlug: site.slug, url: '/', - client: playground, + client: connectedPlayground, opfsMountDescriptor: mountDescriptor, }) ); - (playground as PlaygroundClient).onNavigation((url) => { + if (shouldSyncNewOpfsSiteInBackground && mountDescriptor) { + dispatch( + updateClientInfo({ + siteSlug: site.slug, + changes: { + opfsSync: { status: 'syncing' }, + }, + }) + ); + void connectedPlayground + .mountOpfs( + { + ...mountDescriptor, + initialSyncDirection: 'memfs-to-opfs', + }, + (progress: SyncProgress) => { + dispatch( + updateClientInfo({ + siteSlug: site.slug, + changes: { + opfsSync: { + status: 'syncing', + progress, + }, + }, + }) + ); + } + ) + .then(() => { + dispatch( + updateClientInfo({ + siteSlug: site.slug, + changes: { + opfsSync: undefined, + }, + }) + ); + }) + .catch((error: unknown) => { + logger.error( + 'Error syncing saved Playground to OPFS', + error + ); + dispatch( + updateClientInfo({ + siteSlug: site.slug, + changes: { + opfsSync: { + status: 'error', + }, + }, + }) + ); + }); + } + + connectedPlayground.onNavigation((url) => { dispatch( updateClientInfo({ siteSlug: site.slug, From 88865dc33b8961d198937e3a57256918ae2c5760 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Mon, 18 May 2026 14:25:18 +0200 Subject: [PATCH 12/47] Capture writes during background OPFS sync --- .../src/lib/directory-handle-mount.spec.ts | 38 ++++++++++++++++++- .../web/src/lib/directory-handle-mount.ts | 27 ++++++++----- 2 files changed, 55 insertions(+), 10 deletions(-) diff --git a/packages/php-wasm/web/src/lib/directory-handle-mount.spec.ts b/packages/php-wasm/web/src/lib/directory-handle-mount.spec.ts index 40dd54a077e..00ac7741cfe 100644 --- a/packages/php-wasm/web/src/lib/directory-handle-mount.spec.ts +++ b/packages/php-wasm/web/src/lib/directory-handle-mount.spec.ts @@ -2,7 +2,10 @@ import { describe, expect, it, vi } from 'vitest'; import { __private__dont__use, type PHP } from '@php-wasm/universal'; import { Semaphore } from '@php-wasm/util'; import { logger } from '@php-wasm/logger'; -import { journalFSEventsToOpfs } from './directory-handle-mount'; +import { + createDirectoryHandleMountHandler, + journalFSEventsToOpfs, +} from './directory-handle-mount'; class MemoryFileHandle { kind = 'file' as const; @@ -355,9 +358,42 @@ describe('journalFSEventsToOpfs', () => { }); }); +describe('createDirectoryHandleMountHandler', () => { + it('flushes changes made while the initial MEMFS to OPFS sync is still running', async () => { + let changedDuringInitialSync = false; + const { FS, files, php } = createFakePhp(); + const opfsRoot = new MemoryDirectoryHandle('root', () => { + if (changedDuringInitialSync) { + return; + } + changedDuringInitialSync = true; + files.set('/wordpress/database.sqlite', encode('changed')); + FS.write({ path: '/wordpress/database.sqlite' }); + }); + files.set('/wordpress/database.sqlite', encode('initial')); + + const mountHandler = createDirectoryHandleMountHandler( + opfsRoot as unknown as FileSystemDirectoryHandle, + { + initialSync: { + direction: 'memfs-to-opfs', + }, + } + ); + + await mountHandler(php, FS as any, '/wordpress'); + + expect(decode(opfsRoot.files.get('database.sqlite')!.bytes)).toBe( + 'changed' + ); + }); +}); + function createFakePhp() { const files = new Map(); const FS = { + mkdirTree: vi.fn(), + readdir: vi.fn(() => ['.', '..', 'database.sqlite']), write: vi.fn(), truncate: vi.fn(), unlink: vi.fn(), diff --git a/packages/php-wasm/web/src/lib/directory-handle-mount.ts b/packages/php-wasm/web/src/lib/directory-handle-mount.ts index 1e1cb33f122..390303cabe3 100644 --- a/packages/php-wasm/web/src/lib/directory-handle-mount.ts +++ b/packages/php-wasm/web/src/lib/directory-handle-mount.ts @@ -74,17 +74,26 @@ export function createDirectoryHandleMountHandler( } FSHelpers.mkdir(FS, vfsMountPoint); await copyOpfsToMemfs(FS, handle, vfsMountPoint); + const mount = journalFSEventsToOpfs(php, handle, vfsMountPoint); + options.onMount?.(mount); + return mount.unmount; } else { - await copyMemfsToOpfs( - FS, - handle, - vfsMountPoint, - options.initialSync.onProgress - ); + const mount = journalFSEventsToOpfs(php, handle, vfsMountPoint); + options.onMount?.(mount); + try { + await copyMemfsToOpfs( + FS, + handle, + vfsMountPoint, + options.initialSync.onProgress + ); + await mount.flush(); + } catch (error) { + await mount.unmount(); + throw error; + } + return mount.unmount; } - const mount = journalFSEventsToOpfs(php, handle, vfsMountPoint); - options.onMount?.(mount); - return mount.unmount; }; } From 02d94ade6cad051b831f2830b8975c4df9fa9d16 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Tue, 19 May 2026 01:01:39 +0200 Subject: [PATCH 13/47] Distinguish autosaved Playground recovery copies --- .../src/lib/directory-handle-mount.spec.ts | 31 ++++ .../web/src/lib/directory-handle-mount.ts | 25 +++- .../website/playwright/e2e/website-ui.spec.ts | 32 ++++- .../save-status-indicator.module.css | 8 ++ .../browser-chrome/save-status-indicator.tsx | 75 ++++++++-- .../src/components/save-site-modal/index.tsx | 13 +- .../saved-playgrounds-overlay/index.tsx | 67 ++++++--- .../site-manager/site-info-panel/index.tsx | 18 +++ .../src/lib/state/redux/boot-site-client.ts | 16 ++- .../lib/state/redux/persist-temporary-site.ts | 2 + .../src/lib/state/redux/site-lifecycle.ts | 52 +++++++ .../redux/site-management-api-middleware.ts | 40 +++++- .../src/lib/state/redux/slice-sites.spec.ts | 110 +++++++++++++++ .../src/lib/state/redux/slice-sites.ts | 83 +++++++++-- plan.md | 132 ++++++++++++++++++ 15 files changed, 649 insertions(+), 55 deletions(-) create mode 100644 packages/playground/website/src/lib/state/redux/site-lifecycle.ts create mode 100644 plan.md diff --git a/packages/php-wasm/web/src/lib/directory-handle-mount.spec.ts b/packages/php-wasm/web/src/lib/directory-handle-mount.spec.ts index 00ac7741cfe..726dddb703f 100644 --- a/packages/php-wasm/web/src/lib/directory-handle-mount.spec.ts +++ b/packages/php-wasm/web/src/lib/directory-handle-mount.spec.ts @@ -387,6 +387,37 @@ describe('createDirectoryHandleMountHandler', () => { 'changed' ); }); + + it('reports a flushing phase after the initial MEMFS to OPFS copy', async () => { + const progressEvents: Array<{ + files: number; + total: number; + phase?: 'copying' | 'flushing'; + }> = []; + const { FS, files, php } = createFakePhp(); + const opfsRoot = new MemoryDirectoryHandle('root'); + files.set('/wordpress/database.sqlite', encode('initial')); + + const mountHandler = createDirectoryHandleMountHandler( + opfsRoot as unknown as FileSystemDirectoryHandle, + { + initialSync: { + direction: 'memfs-to-opfs', + onProgress: (progress) => { + progressEvents.push(progress); + }, + }, + } + ); + + await mountHandler(php, FS as any, '/wordpress'); + + expect(progressEvents).toContainEqual({ + files: 1, + total: 1, + phase: 'flushing', + }); + }); }); function createFakePhp() { diff --git a/packages/php-wasm/web/src/lib/directory-handle-mount.ts b/packages/php-wasm/web/src/lib/directory-handle-mount.ts index 390303cabe3..12204c5d414 100644 --- a/packages/php-wasm/web/src/lib/directory-handle-mount.ts +++ b/packages/php-wasm/web/src/lib/directory-handle-mount.ts @@ -46,6 +46,8 @@ export type SyncProgress = { files: number; /** The number of all files that need to be synced. */ total: number; + /** The current stage of the initial sync. */ + phase?: 'copying' | 'flushing'; }; export type SyncProgressCallback = (progress: SyncProgress) => void; @@ -80,13 +82,20 @@ export function createDirectoryHandleMountHandler( } else { const mount = journalFSEventsToOpfs(php, handle, vfsMountPoint); options.onMount?.(mount); + let lastProgress: SyncProgress | undefined; try { - await copyMemfsToOpfs( - FS, - handle, - vfsMountPoint, - options.initialSync.onProgress - ); + await copyMemfsToOpfs(FS, handle, vfsMountPoint, (progress) => { + lastProgress = { + ...progress, + phase: 'copying', + }; + options.initialSync.onProgress?.(lastProgress); + }); + options.initialSync.onProgress?.({ + files: lastProgress?.total ?? 0, + total: lastProgress?.total ?? 0, + phase: 'flushing', + }); await mount.flush(); } catch (error) { await mount.unmount(); @@ -249,6 +258,10 @@ export async function copyMemfsToOpfs( // to a conflict with writes from the earlier attempt. await Promise.allSettled(concurrentWrites); } + onProgress?.({ + files: filesToCreate.length, + total: filesToCreate.length, + }); } function isMemfsDir(FS: Emscripten.RootFS, path: string) { diff --git a/packages/playground/website/playwright/e2e/website-ui.spec.ts b/packages/playground/website/playwright/e2e/website-ui.spec.ts index d5df6d18264..4ff7665a61a 100644 --- a/packages/playground/website/playwright/e2e/website-ui.spec.ts +++ b/packages/playground/website/playwright/e2e/website-ui.spec.ts @@ -596,7 +596,7 @@ test.describe('Database panel', () => { // Test browser-saved Playgrounds by default and explicit temporary opt-outs. test.describe('Default Playground storage', () => { - test('should create a browser-saved Playground by default', async ({ + test('should create a browser-autosaved Playground by default', async ({ website, browserName, }) => { @@ -619,6 +619,11 @@ test.describe('Default Playground storage', () => { await expect(website.page.getByText('Unsaved Playground')).toHaveCount( 0 ); + await expect( + website.page.getByText( + /Autosaved Playground|Autosaving|Finalizing autosave/ + ) + ).toBeVisible(); }); test('should start an unsaved Playground from the overlay opt-out', async ({ @@ -647,7 +652,7 @@ test.describe('Default Playground storage', () => { ).toBeVisible(); }); - test('should show browser storage details in the Site Manager by default', async ({ + test('should show autosave browser storage details in the Site Manager by default', async ({ website, browserName, }) => { @@ -660,7 +665,7 @@ test.describe('Default Playground storage', () => { await website.ensureSiteManagerIsOpen(); await expect( - website.page.getByText('Saved in this browser') + website.page.getByText('Autosaved in this browser') ).toBeVisible(); await expect( website.page.getByText( @@ -669,6 +674,27 @@ test.describe('Default Playground storage', () => { ).toHaveCount(0); }); + test('should promote a default autosaved Playground when kept', async ({ + website, + browserName, + }) => { + test.skip( + browserName !== 'chromium', + `Saved-by-default Playgrounds rely on OPFS, which is not available in Playwright's ${browserName}.` + ); + + await website.goto('./'); + await website.ensureSiteManagerIsClosed(); + const keepButton = website.page.getByRole('button', { name: 'Keep' }); + await expect(keepButton).toBeVisible({ timeout: 120000 }); + await keepButton.click(); + + await expect(website.page.getByText('Saved Playground')).toBeVisible(); + await expect( + website.page.getByText('Autosaved Playground') + ).toHaveCount(0); + }); + test('should persist WordPress changes after refreshing the default Playground', async ({ website, browserName, diff --git a/packages/playground/website/src/components/browser-chrome/save-status-indicator.module.css b/packages/playground/website/src/components/browser-chrome/save-status-indicator.module.css index 2e2bd7ba167..3ce356cfa2a 100644 --- a/packages/playground/website/src/components/browser-chrome/save-status-indicator.module.css +++ b/packages/playground/website/src/components/browser-chrome/save-status-indicator.module.css @@ -25,6 +25,14 @@ fill: #86efac; } +.autosaved { + color: #93c5fd; +} + +.autosaved svg { + fill: #93c5fd; +} + .unsaved { color: #fcd34d; } diff --git a/packages/playground/website/src/components/browser-chrome/save-status-indicator.tsx b/packages/playground/website/src/components/browser-chrome/save-status-indicator.tsx index d186668f2e4..59d50c257a8 100644 --- a/packages/playground/website/src/components/browser-chrome/save-status-indicator.tsx +++ b/packages/playground/website/src/components/browser-chrome/save-status-indicator.tsx @@ -10,12 +10,18 @@ import { import { modalSlugs, setActiveModal } from '../../lib/state/redux/slice-ui'; import { Icon } from '@wordpress/components'; import { check, cautionFilled } from '@wordpress/icons'; +import { + isAutosavedSite, + preserveSite, + type SiteInfo, +} from '../../lib/state/redux/slice-sites'; +import type { OpfsSync } from '../../lib/state/redux/slice-clients'; -type SaveStatus = 'saved' | 'unsaved' | 'saving' | 'error'; +type SaveStatus = 'saved' | 'autosaved' | 'unsaved' | 'saving' | 'error'; function getSaveStatus( - storage: string | undefined, - opfsSync: { status: string } | undefined + site: SiteInfo | undefined, + opfsSync: OpfsSync | undefined ): SaveStatus { if (opfsSync?.status === 'syncing') { return 'saving'; @@ -23,25 +29,56 @@ function getSaveStatus( if (opfsSync?.status === 'error') { return 'error'; } + const storage = site?.metadata.storage; if (storage === 'none' || !storage) { return 'unsaved'; } + if (site && isAutosavedSite(site)) { + return 'autosaved'; + } return 'saved'; } +function getSyncLabel({ + isAutosaved, + progress, +}: { + isAutosaved: boolean; + progress: Extract['progress']; +}) { + if ( + progress?.phase === 'flushing' || + (progress && progress.total > 0 && progress.files >= progress.total) + ) { + return isAutosaved ? 'Finalizing autosave...' : 'Finalizing save...'; + } + if (progress) { + return isAutosaved + ? `Autosaving ${progress.files}/${progress.total}...` + : `Saving ${progress.files}/${progress.total}...`; + } + return isAutosaved ? 'Autosaving...' : 'Saving...'; +} + export function SaveStatusIndicator() { const clientInfo = useAppSelector(getActiveClientInfo); const activeSite = useActiveSite(); const dispatch = useAppDispatch(); - const storage = activeSite?.metadata?.storage; const opfsSync = clientInfo?.opfsSync; - const status = getSaveStatus(storage, opfsSync); + const status = getSaveStatus(activeSite, opfsSync); + const isAutosaved = activeSite ? isAutosavedSite(activeSite) : false; const handleSaveClick = () => { dispatch(setActiveModal(modalSlugs.SAVE_SITE)); }; + const handleKeepClick = () => { + if (activeSite) { + void dispatch(preserveSite(activeSite.slug)); + } + }; + if (status === 'saved') { return (
@@ -51,18 +88,30 @@ export function SaveStatusIndicator() { ); } + if (status === 'autosaved') { + return ( +
+ + Autosaved Playground + +
+ ); + } + if (status === 'saving') { const progress = - opfsSync?.status === 'syncing' - ? (opfsSync as any).progress - : undefined; + opfsSync?.status === 'syncing' ? opfsSync.progress : undefined; return (
- {progress - ? `Saving ${progress.files}/${progress.total}...` - : 'Saving...'} + {getSyncLabel({ isAutosaved, progress })}
); @@ -76,7 +125,9 @@ export function SaveStatusIndicator() { type="button" > - Save failed + + {isAutosaved ? 'Autosave failed' : 'Save failed'} + ); } 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 675b3ffe610..2506ffe8a12 100644 --- a/packages/playground/website/src/components/save-site-modal/index.tsx +++ b/packages/playground/website/src/components/save-site-modal/index.tsx @@ -279,6 +279,15 @@ export function SaveSiteModal() { !selectionIsAvailable || !hasDirectoryAccess || isSaving; + const savingProgressLabel = + savingProgress?.phase === 'flushing' || + (savingProgress && + savingProgress.total > 0 && + savingProgress.files >= savingProgress.total) + ? 'Finalizing save...' + : savingProgress + ? `Saving ${savingProgress.files} / ${savingProgress.total} files...` + : 'Preparing to save...'; const handleRequestClose = () => { if (!isSaving) { @@ -386,9 +395,7 @@ export function SaveSiteModal() { style={{ width: '100%', height: 24 }} >

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

)} diff --git a/packages/playground/website/src/components/saved-playgrounds-overlay/index.tsx b/packages/playground/website/src/components/saved-playgrounds-overlay/index.tsx index 3764d5741ff..88a08fe3789 100644 --- a/packages/playground/website/src/components/saved-playgrounds-overlay/index.tsx +++ b/packages/playground/website/src/components/saved-playgrounds-overlay/index.tsx @@ -22,6 +22,7 @@ import { import type { PlaygroundDispatch } from '../../lib/state/redux/store'; import type { SiteLogo, SiteInfo } from '../../lib/state/redux/slice-sites'; import { + isAutosavedSite, selectSortedSites, selectTemporarySite, } from '../../lib/state/redux/slice-sites'; @@ -255,6 +256,21 @@ export function SavedPlaygroundsOverlay({ closeMenu(); }; + const handleKeepSite = async (site: SiteInfo, closeMenu: () => void) => { + await sitesAPI.keep(site.slug); + closeMenu(); + }; + + const getStoredSiteDetails = (site: SiteInfo) => { + if (isAutosavedSite(site)) { + return 'Autosaved - kept with the latest 5 autosaves'; + } + if (site.metadata.storage === 'local-fs') { + return 'Saved in a local directory'; + } + return 'Saved in this browser'; + }; + function previewBlueprint(blueprintPath: BlueprintsIndexEntry['path']) { dispatch(setSiteManagerOpen(false)); redirectTo( @@ -657,6 +673,15 @@ export function SavedPlaygroundsOverlay({ {storedSites.map((site) => { const isSelected = site.slug === activeSite?.slug; + const createdDate = site.metadata.whenCreated + ? new Date( + site.metadata.whenCreated + ).toLocaleDateString(undefined, { + year: 'numeric', + month: 'short', + day: 'numeric', + }) + : undefined; return (
{site.metadata.name} - {site.metadata.whenCreated && ( - - Created{' '} - {new Date( - site.metadata - .whenCreated - ).toLocaleDateString( - undefined, - { - year: 'numeric', - month: 'short', - day: 'numeric', - } - )} - - )} + + {getStoredSiteDetails(site)} + {createdDate + ? ` - Created ${createdDate}` + : ''} +
( <> + {isAutosavedSite( + site + ) && ( + + handleKeepSite( + site, + closeMenu + ) + } + > + Keep + + )} handleRenameSite( 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 689d612a074..c24a8215570 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 @@ -15,6 +15,10 @@ import { lazy, Suspense, useEffect, useState } from 'react'; import { getRelativeDate } from '../../../lib/get-relative-date'; import { selectClientInfoBySiteSlug } from '../../../lib/state/redux/slice-clients'; import type { SiteInfo } from '../../../lib/state/redux/slice-sites'; +import { + isAutosavedSite, + preserveSite, +} from '../../../lib/state/redux/slice-sites'; import { modalSlugs, setActiveModal, @@ -99,12 +103,16 @@ export function SiteInfoPanel({ }; const isTemporary = site.metadata.storage === 'none'; + const isAutosaved = isAutosavedSite(site); const removeSiteAndCloseMenu = (onClose: () => void) => { dispatch(setSiteSlugToDelete(site.slug)); dispatch(setActiveModal(modalSlugs.DELETE_SITE)); onClose(); }; + const keepSite = () => { + void dispatch(preserveSite(site.slug)); + }; const clientInfo = useAppSelector((state) => selectClientInfoBySiteSlug(state, site.slug) ); @@ -281,6 +289,9 @@ export function SiteInfoPanel({ ` ${createdAgo}` ); case 'opfs': + if (isAutosaved) { + return `Autosaved in this browser ${createdAgo}. Kept with the latest 5 autosaves.`; + } return `Saved in this browser ${createdAgo}`; } })()}{' '} @@ -288,6 +299,13 @@ export function SiteInfoPanel({ )} + {isAutosaved && ( + + + + )} {mobileUi ? ( + )} ); } From 009a4b64bf070c696bd76da27388139888ed255c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Tue, 19 May 2026 01:52:51 +0200 Subject: [PATCH 15/47] Clarify autosaved Playground retention UX --- .../website/playwright/e2e/website-ui.spec.ts | 12 +++++++++++- .../browser-chrome/save-status-indicator.tsx | 8 ++++---- .../components/saved-playgrounds-overlay/index.tsx | 4 ++-- .../site-manager/site-info-panel/index.tsx | 4 ++-- plan.md | 10 +++++----- 5 files changed, 24 insertions(+), 14 deletions(-) diff --git a/packages/playground/website/playwright/e2e/website-ui.spec.ts b/packages/playground/website/playwright/e2e/website-ui.spec.ts index cc45bd04b83..9b8f0b3ce72 100644 --- a/packages/playground/website/playwright/e2e/website-ui.spec.ts +++ b/packages/playground/website/playwright/e2e/website-ui.spec.ts @@ -667,6 +667,14 @@ test.describe('Default Playground storage', () => { await expect( website.page.getByText('Autosaved in this browser') ).toBeVisible(); + await expect( + website.page.getByText( + 'Removed after 5 newer autosaves unless kept.' + ) + ).toBeVisible(); + await expect( + website.page.getByRole('button', { name: 'Keep forever' }) + ).toBeVisible(); await expect( website.page.getByText( 'This is an Unsaved Playground. Your changes will be lost on page refresh.' @@ -685,7 +693,9 @@ test.describe('Default Playground storage', () => { await website.goto('./'); await website.ensureSiteManagerIsClosed(); - const keepButton = website.page.getByRole('button', { name: 'Keep' }); + const keepButton = website.page.getByRole('button', { + name: 'Keep forever', + }); await expect(keepButton).toBeVisible({ timeout: 120000 }); await keepButton.click(); diff --git a/packages/playground/website/src/components/browser-chrome/save-status-indicator.tsx b/packages/playground/website/src/components/browser-chrome/save-status-indicator.tsx index 84efedc78ed..fa1219ca76c 100644 --- a/packages/playground/website/src/components/browser-chrome/save-status-indicator.tsx +++ b/packages/playground/website/src/components/browser-chrome/save-status-indicator.tsx @@ -9,7 +9,7 @@ import { } from '../../lib/state/redux/store'; import { modalSlugs, setActiveModal } from '../../lib/state/redux/slice-ui'; import { Icon } from '@wordpress/components'; -import { check, cautionFilled } from '@wordpress/icons'; +import { backup, check, cautionFilled } from '@wordpress/icons'; import { isAutosavedSite, preserveSite, @@ -91,14 +91,14 @@ export function SaveStatusIndicator() { if (status === 'autosaved') { return (
- + Autosaved Playground
); @@ -119,7 +119,7 @@ export function SaveStatusIndicator() { onClick={handleKeepClick} type="button" > - Keep + Keep forever )} diff --git a/packages/playground/website/src/components/saved-playgrounds-overlay/index.tsx b/packages/playground/website/src/components/saved-playgrounds-overlay/index.tsx index 88a08fe3789..0b2783141d3 100644 --- a/packages/playground/website/src/components/saved-playgrounds-overlay/index.tsx +++ b/packages/playground/website/src/components/saved-playgrounds-overlay/index.tsx @@ -263,7 +263,7 @@ export function SavedPlaygroundsOverlay({ const getStoredSiteDetails = (site: SiteInfo) => { if (isAutosavedSite(site)) { - return 'Autosaved - kept with the latest 5 autosaves'; + return 'Autosaved - removed after 5 newer autosaves'; } if (site.metadata.storage === 'local-fs') { return 'Saved in a local directory'; @@ -745,7 +745,7 @@ export function SavedPlaygroundsOverlay({ ) } > - Keep + Keep forever
)} )} diff --git a/plan.md b/plan.md index 127cb627b99..0f289c1cd10 100644 --- a/plan.md +++ b/plan.md @@ -41,7 +41,7 @@ The useful pattern for Playground is: ### Browser Chrome Status - Temporary site: `Unsaved Playground` with the existing `Save` button. -- Autosaved site, idle: `Autosaved Playground` with a `Keep` button. +- Autosaved site, idle: `Autosaved Playground` with a `Keep forever` button. - Autosaved site, initial sync active: `Autosaving...` or `Finalizing autosave...`. - Explicitly preserved site: `Saved Playground`. - OPFS sync error on autosaved site: `Autosave failed`. @@ -53,17 +53,17 @@ This avoids saying "Saved Playground" before the user has explicitly kept it. Show all non-temporary browser/local sites in `Your Playgrounds`, but label their lifecycle: -- Autosaved rows: `Autosaved · kept with the latest 5 autosaves` +- Autosaved rows: `Autosaved · removed after 5 newer autosaves` - Explicit rows: `Saved in this browser` or `Saved in a local directory` -Autosaved rows should include a `Keep` action in the row menu. Keeping promotes the site to the explicit saved lifecycle. +Autosaved rows should include a `Keep forever` action in the row menu. Keeping promotes the site to the explicit saved lifecycle. ### Site Manager Details -- Autosaved OPFS sites should say: `Autosaved in this browser. Kept with the latest 5 autosaves.` +- Autosaved OPFS sites should say: `Autosaved in this browser. Removed after 5 newer autosaves unless kept.` - Explicit OPFS sites should continue to say: `Saved in this browser`. - Local filesystem sites should continue to say: `Saved in a local directory`. -- Autosaved sites should expose a `Keep` action near the standard site actions. +- Autosaved sites should expose a `Keep forever` action near the standard site actions. ## Data Model From 1861daa9be57e5ec1ce635bb2a481c5797e6d1b3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Tue, 19 May 2026 02:06:03 +0200 Subject: [PATCH 16/47] Improve autosave promotion affordances --- .../website/playwright/e2e/website-ui.spec.ts | 9 ++++--- .../website/playwright/website-page.ts | 2 +- .../src/components/browser-chrome/index.tsx | 5 +++- .../browser-chrome/save-status-indicator.tsx | 6 +++-- .../browser-chrome/style.module.css | 15 ++++++++++- .../saved-playgrounds-overlay/index.tsx | 2 +- .../site-manager/site-info-panel/index.tsx | 4 +-- plan.md | 26 ++++++++++++++----- 8 files changed, 51 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 9b8f0b3ce72..044ac3ee7f5 100644 --- a/packages/playground/website/playwright/e2e/website-ui.spec.ts +++ b/packages/playground/website/playwright/e2e/website-ui.spec.ts @@ -669,11 +669,14 @@ test.describe('Default Playground storage', () => { ).toBeVisible(); await expect( website.page.getByText( - 'Removed after 5 newer autosaves unless kept.' + 'Removed after 5 newer autosaves unless saved.' ) ).toBeVisible(); + const siteInfoPanel = website.page.locator( + 'section[class*="site-info-panel"]' + ); await expect( - website.page.getByRole('button', { name: 'Keep forever' }) + siteInfoPanel.getByRole('button', { name: 'Save Playground' }) ).toBeVisible(); await expect( website.page.getByText( @@ -694,7 +697,7 @@ test.describe('Default Playground storage', () => { await website.goto('./'); await website.ensureSiteManagerIsClosed(); const keepButton = website.page.getByRole('button', { - name: 'Keep forever', + name: 'Save Playground', }); await expect(keepButton).toBeVisible({ timeout: 120000 }); await keepButton.click(); diff --git a/packages/playground/website/playwright/website-page.ts b/packages/playground/website/playwright/website-page.ts index 4b5510378e8..fdc5f3e95d6 100644 --- a/packages/playground/website/playwright/website-page.ts +++ b/packages/playground/website/playwright/website-page.ts @@ -71,7 +71,7 @@ export class WebsitePage { async openSavedPlaygroundsOverlay() { await this.page - .getByRole('button', { name: 'Saved Playgrounds' }) + .getByRole('button', { name: 'Your Playgrounds' }) .click(); await expect( this.page diff --git a/packages/playground/website/src/components/browser-chrome/index.tsx b/packages/playground/website/src/components/browser-chrome/index.tsx index 1c3e271d739..270b7930aaa 100644 --- a/packages/playground/website/src/components/browser-chrome/index.tsx +++ b/packages/playground/website/src/components/browser-chrome/index.tsx @@ -98,12 +98,15 @@ export default function BrowserChrome({
); @@ -116,10 +117,11 @@ export function SaveStatusIndicator() { {isAutosaved && ( )} diff --git a/packages/playground/website/src/components/browser-chrome/style.module.css b/packages/playground/website/src/components/browser-chrome/style.module.css index d31a7e7c79c..f2225677416 100644 --- a/packages/playground/website/src/components/browser-chrome/style.module.css +++ b/packages/playground/website/src/components/browser-chrome/style.module.css @@ -94,17 +94,26 @@ body.is-embedded .fake-window-wrapper { display: flex; align-items: center; justify-content: center; - padding: 8px; + gap: 6px; + padding: 8px 10px; background: transparent; border: none; cursor: pointer; flex-shrink: 0; + color: #fff; + font-size: 13px; + font-weight: 500; + line-height: 16px; } .saved-playgrounds-button svg { fill: #fff; } +.saved-playgrounds-button-label { + color: #fff; +} + .saved-playgrounds-button:hover { background: rgba(255, 255, 255, 0.1); border-radius: 4px; @@ -142,6 +151,10 @@ body.is-embedded .fake-window-wrapper { .toolbar-buttons { padding: 0; } + + .saved-playgrounds-button-label { + display: none; + } } /* Mobile: split toolbar into two lines */ diff --git a/packages/playground/website/src/components/saved-playgrounds-overlay/index.tsx b/packages/playground/website/src/components/saved-playgrounds-overlay/index.tsx index 0b2783141d3..bd3365704c4 100644 --- a/packages/playground/website/src/components/saved-playgrounds-overlay/index.tsx +++ b/packages/playground/website/src/components/saved-playgrounds-overlay/index.tsx @@ -745,7 +745,7 @@ export function SavedPlaygroundsOverlay({ ) } > - Keep forever + Save Playground
)} )} diff --git a/plan.md b/plan.md index 0f289c1cd10..38eb6bbbabb 100644 --- a/plan.md +++ b/plan.md @@ -12,7 +12,7 @@ That creates two user-facing problems: The product needs two separate concepts: - **Autosaved Playgrounds**: created by default to prevent data loss, stored in browser OPFS, kept only as the latest recovery set. -- **Saved Playgrounds**: explicitly preserved by the user, kept indefinitely unless deleted by the user. +- **Saved Playgrounds**: explicitly preserved by the user, kept by Playground unless deleted by the user or cleared by browser/platform storage policy. ## Evidence From The Repository @@ -21,6 +21,7 @@ The product needs two separate concepts: - [#3169](https://github.com/WordPress/wordpress-playground/issues/3169) shows that users struggle to find saved Playgrounds, so storage state and access points need to be obvious. - [#1659](https://github.com/WordPress/wordpress-playground/issues/1659) identifies site metadata as the right place for creation/storage details and leaves `last active` style timestamps as an open need. - [#3409](https://github.com/WordPress/wordpress-playground/issues/3409) reinforces that browser-managed storage can be cleared by browser/platform policies, so it should not be framed as equivalent to an intentionally preserved export or local filesystem copy. +- [discussion #3032](https://github.com/WordPress/wordpress-playground/discussions/3032) shows that contextual actions for saved Playgrounds can be too hidden, including deletion. ## UX Pattern Comparison @@ -29,11 +30,20 @@ Known autosave products separate safety from intention: - Google Docs exposes save state and version history. Its help docs describe a visible last-edit/version-history affordance, named versions, and limits on version history. - Figma keeps the current file updated automatically, groups autosave checkpoints, and lets users create or name versions for intentional milestones. See Figma's version history docs: https://help.figma.com/hc/en-us/articles/360038006754-View-a-file-s-version-history - Slack automatically saves unsent text as drafts, but "draft" is not presented as a sent or intentionally saved message. See Slack's message docs: https://slack.com/help/articles/201457107-Send-and-read-messages +- StackBlitz distinguishes automatic in-memory sync from persistence, noting that writing to the in-memory filesystem is not the same as a user Save action. See StackBlitz project configuration docs: https://developer.stackblitz.com/platform/webcontainers/project-config +- CodePen keeps autosave behind an explicit first save because not every experiment should be preserved, and still recommends the Save button when users want the latest version to be shareable. See CodePen autosave docs: https://blog.codepen.io/documentation/autosave/ +- VS Code separates Auto Save from Hot Exit recovery: autosave writes to disk, while hot exit remembers unsaved work after exit. See VS Code editing docs: https://code.visualstudio.com/docs/editing/codebasics + +Human-perception guidance: + +- Nielsen Norman Group's visibility-of-system-status heuristic says consequential system state should be communicated continuously and in understandable terms. +- Recognition beats recall: important actions and locations should be visible or easily retrievable instead of hidden behind icon-only controls. +- Consistency and standards matter: users bring expectations from other tools, where "Save" is the explicit preservation action and autosave is a recovery/sync behavior. The useful pattern for Playground is: - Autosave copy: low-friction, automatic, recoverable, retention-limited. -- Explicit save/keep action: user intent, durable until deleted. +- Explicit save action: user intent, retained by Playground unless deleted or cleared by browser/platform storage policy. - Status text should describe the current operation, not expose misleading implementation counters after the copy phase has completed. ## Proposed UX @@ -41,13 +51,15 @@ The useful pattern for Playground is: ### Browser Chrome Status - Temporary site: `Unsaved Playground` with the existing `Save` button. -- Autosaved site, idle: `Autosaved Playground` with a `Keep forever` button. +- Autosaved site, idle: `Autosaved Playground` with a `Save` button that promotes it to `Saved Playground`. - Autosaved site, initial sync active: `Autosaving...` or `Finalizing autosave...`. - Explicitly preserved site: `Saved Playground`. - OPFS sync error on autosaved site: `Autosave failed`. - OPFS sync error on explicit saved site: `Save failed`. -This avoids saying "Saved Playground" before the user has explicitly kept it. +This avoids saying "Saved Playground" before the user has explicitly saved it, and it avoids "forever" language for browser-managed storage that may still be cleared by browser or platform policy. + +The toolbar should also expose a visible `Your Playgrounds` label on desktop instead of relying only on an icon. On smaller screens it can collapse back to the icon-only button to protect the address bar and status layout. ### Your Playgrounds Overlay @@ -56,14 +68,14 @@ Show all non-temporary browser/local sites in `Your Playgrounds`, but label thei - Autosaved rows: `Autosaved · removed after 5 newer autosaves` - Explicit rows: `Saved in this browser` or `Saved in a local directory` -Autosaved rows should include a `Keep forever` action in the row menu. Keeping promotes the site to the explicit saved lifecycle. +Autosaved rows should include a `Save Playground` action in the row menu. Saving promotes the site to the explicit saved lifecycle. ### Site Manager Details -- Autosaved OPFS sites should say: `Autosaved in this browser. Removed after 5 newer autosaves unless kept.` +- Autosaved OPFS sites should say: `Autosaved in this browser. Removed after 5 newer autosaves unless saved.` - Explicit OPFS sites should continue to say: `Saved in this browser`. - Local filesystem sites should continue to say: `Saved in a local directory`. -- Autosaved sites should expose a `Keep forever` action near the standard site actions. +- Autosaved sites should expose a `Save Playground` action near the standard site actions. ## Data Model From 986122be1914f2161e03089d185cd977b1ecdc69 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Tue, 19 May 2026 15:24:05 +0200 Subject: [PATCH 17/47] Fix autosave completion and saved playgrounds overlay --- .../src/lib/directory-handle-mount.spec.ts | 34 ++ .../web/src/lib/directory-handle-mount.ts | 23 +- .../saved-playgrounds-overlay/index.tsx | 351 ++++++++++-------- .../style.module.css | 110 ++++-- 4 files changed, 327 insertions(+), 191 deletions(-) diff --git a/packages/php-wasm/web/src/lib/directory-handle-mount.spec.ts b/packages/php-wasm/web/src/lib/directory-handle-mount.spec.ts index 44cb1a27317..91671969917 100644 --- a/packages/php-wasm/web/src/lib/directory-handle-mount.spec.ts +++ b/packages/php-wasm/web/src/lib/directory-handle-mount.spec.ts @@ -3,6 +3,7 @@ import { __private__dont__use, type PHP } from '@php-wasm/universal'; import { Semaphore } from '@php-wasm/util'; import { logger } from '@php-wasm/logger'; import { + copyMemfsToOpfs, createDirectoryHandleMountHandler, journalFSEventsToOpfs, } from './directory-handle-mount'; @@ -458,6 +459,39 @@ describe('createDirectoryHandleMountHandler', () => { phase: 'flushing', }); }); + + it('does not emit stale copy progress after the final progress event', async () => { + vi.useFakeTimers(); + + try { + const progressEvents: Array<{ files: number; total: number }> = []; + const { FS, files } = createFakePhp(); + const opfsRoot = new MemoryDirectoryHandle('root'); + FS.readdir.mockReturnValue(['.', '..', 'first.txt', 'second.txt']); + files.set('/wordpress/first.txt', encode('first')); + files.set('/wordpress/second.txt', encode('second')); + + await copyMemfsToOpfs( + FS as any, + opfsRoot as unknown as FileSystemDirectoryHandle, + '/wordpress', + (progress) => { + progressEvents.push(progress); + } + ); + + const progressEventCount = progressEvents.length; + await vi.advanceTimersByTimeAsync(1000); + + expect(progressEvents).toHaveLength(progressEventCount); + expect(progressEvents.at(-1)).toEqual({ + files: 2, + total: 2, + }); + } finally { + vi.useRealTimers(); + } + }); }); function createFakePhp() { diff --git a/packages/php-wasm/web/src/lib/directory-handle-mount.ts b/packages/php-wasm/web/src/lib/directory-handle-mount.ts index 171eacd4504..6ea9fa2bb6a 100644 --- a/packages/php-wasm/web/src/lib/directory-handle-mount.ts +++ b/packages/php-wasm/web/src/lib/directory-handle-mount.ts @@ -260,6 +260,7 @@ export async function copyMemfsToOpfs( // to a conflict with writes from the earlier attempt. await Promise.allSettled(concurrentWrites); } + throttledProgressCallback?.cancel(); onProgress?.({ files: filesToCreate.length, total: filesToCreate.length, @@ -556,15 +557,21 @@ async function resolveParent( return handle as any; } +type CancelableThrottledFunction any> = T & { + cancel(): void; +}; + function throttle any>( fn: T, debounceMs: number -): T { +): CancelableThrottledFunction { let lastCallTime = 0; let timeoutId: ReturnType | undefined; let pendingArgs: Parameters | undefined; - return function throttledCallback(...args: Parameters) { + const throttledCallback = function throttledCallback( + ...args: Parameters + ) { pendingArgs = args; const timeSinceLastCall = Date.now() - lastCallTime; @@ -576,5 +583,15 @@ function throttle any>( fn(...pendingArgs!); }, delay); } - } as T; + } as CancelableThrottledFunction; + + throttledCallback.cancel = () => { + if (timeoutId !== undefined) { + clearTimeout(timeoutId); + } + timeoutId = undefined; + pendingArgs = undefined; + }; + + return throttledCallback; } diff --git a/packages/playground/website/src/components/saved-playgrounds-overlay/index.tsx b/packages/playground/website/src/components/saved-playgrounds-overlay/index.tsx index bd3365704c4..09412dab42a 100644 --- a/packages/playground/website/src/components/saved-playgrounds-overlay/index.tsx +++ b/packages/playground/website/src/components/saved-playgrounds-overlay/index.tsx @@ -23,6 +23,8 @@ import type { PlaygroundDispatch } from '../../lib/state/redux/store'; import type { SiteLogo, SiteInfo } from '../../lib/state/redux/slice-sites'; import { isAutosavedSite, + isExplicitlySavedSite, + MAX_AUTOSAVED_SITES, selectSortedSites, selectTemporarySite, } from '../../lib/state/redux/slice-sites'; @@ -45,6 +47,8 @@ import { OverlaySection, } from '../overlay'; +const MAX_VISIBLE_SAVED_SITES = 8; + type BlueprintsIndexEntry = { title: string; description: string; @@ -86,6 +90,10 @@ export function SavedPlaygroundsOverlay({ const storedSites = useAppSelector(selectSortedSites).filter( (site) => site.metadata.storage !== 'none' ); + const explicitlySavedSites = storedSites.filter(isExplicitlySavedSite); + const autosavedSites = storedSites + .filter(isAutosavedSite) + .slice(0, MAX_AUTOSAVED_SITES); const temporarySite = useAppSelector(selectTemporarySite); const activeSite = useActiveSite(); const dispatch = useAppDispatch(); @@ -93,11 +101,11 @@ export function SavedPlaygroundsOverlay({ const sitesAPI = useSitesAPI(); const playground = usePlaygroundClient(); const zipFileInputRef = useRef(null); - const yourPlaygroundsRef = useRef(null); const [viewMode, setViewMode] = useState(initialViewMode); const [searchQuery, setSearchQuery] = useState(''); const [selectedTag, setSelectedTag] = useState(null); + const [showAllSavedSites, setShowAllSavedSites] = useState(false); const [pendingZipFile, setPendingZipFile] = useState(null); const [pendingZipTargetSlug, setPendingZipTargetSlug] = useState< string | null @@ -256,14 +264,14 @@ export function SavedPlaygroundsOverlay({ closeMenu(); }; - const handleKeepSite = async (site: SiteInfo, closeMenu: () => void) => { + const handleKeepSite = async (site: SiteInfo, closeMenu?: () => void) => { await sitesAPI.keep(site.slug); - closeMenu(); + closeMenu?.(); }; const getStoredSiteDetails = (site: SiteInfo) => { if (isAutosavedSite(site)) { - return 'Autosaved - removed after 5 newer autosaves'; + return 'Recovery copy'; } if (site.metadata.storage === 'local-fs') { return 'Saved in a local directory'; @@ -349,6 +357,118 @@ export function SavedPlaygroundsOverlay({ }, ]; + const visibleSavedSites = showAllSavedSites + ? explicitlySavedSites + : explicitlySavedSites.slice(0, MAX_VISIBLE_SAVED_SITES); + const hiddenSavedSitesCount = + explicitlySavedSites.length - visibleSavedSites.length; + + function formatSiteCreatedDate(site: SiteInfo) { + return site.metadata.whenCreated + ? new Date(site.metadata.whenCreated).toLocaleDateString( + undefined, + { + year: 'numeric', + month: 'short', + day: 'numeric', + } + ) + : undefined; + } + + function renderSiteRow(site: SiteInfo) { + const isSelected = site.slug === activeSite?.slug; + const isAutosave = isAutosavedSite(site); + const createdDate = formatSiteCreatedDate(site); + + return ( +
+ +
+ {isAutosave && ( + + )} + + {({ onClose: closeMenu }) => ( + <> + + {isAutosave && ( + + handleKeepSite(site, closeMenu) + } + > + Save Playground + + )} + + handleRenameSite(site, closeMenu) + } + > + Rename + + + + + handleDeleteSite(site, closeMenu) + } + > + Delete + + + + )} + +
+
+ ); + } + if (viewMode === 'blueprints') { return ( @@ -531,19 +651,6 @@ export function SavedPlaygroundsOverlay({ style={{ display: 'none' }} /> -
- -
@@ -634,155 +741,75 @@ export function SavedPlaygroundsOverlay({ )} -
- -
-
+
+
+ -
- {storedSites.map((site) => { - const isSelected = - site.slug === activeSite?.slug; - const createdDate = site.metadata.whenCreated - ? new Date( - site.metadata.whenCreated - ).toLocaleDateString(undefined, { - year: 'numeric', - month: 'short', - day: 'numeric', - }) - : undefined; - return ( -
- - - {({ onClose: closeMenu }) => ( - <> - - {isAutosavedSite( - site - ) && ( - - handleKeepSite( - site, - closeMenu - ) - } - > - Save Playground - - )} - - handleRenameSite( - site, - closeMenu - ) - } - > - Rename - - - - - handleDeleteSite( - site, - closeMenu - ) - } - > - Delete - - - +
+ {temporarySite?.metadata.logo ? ( + -
- ); - })} + alt="" + /> + ) : ( + + )} +
+
+ + Unsaved Playground + + + Not saved to browser storage + +
+ +
+ {visibleSavedSites.map(renderSiteRow)} +
+ {hiddenSavedSitesCount > 0 && ( + + )} + + + {autosavedSites.length > 0 && ( + +
+ + Last {MAX_AUTOSAVED_SITES} + + + Auto-delete + + + Use Save to keep one. + +
+
+ {autosavedSites.map(renderSiteRow)}
-
+ )} ); diff --git a/packages/playground/website/src/components/saved-playgrounds-overlay/style.module.css b/packages/playground/website/src/components/saved-playgrounds-overlay/style.module.css index 01a360b68ef..a5e9ae29338 100644 --- a/packages/playground/website/src/components/saved-playgrounds-overlay/style.module.css +++ b/packages/playground/website/src/components/saved-playgrounds-overlay/style.module.css @@ -77,32 +77,6 @@ border-radius: 8px; } -.quickNav { - display: flex; - justify-content: center; - gap: 8px; - padding: 10px 24px; - border-bottom: 1px solid #2c3338; - background: #1e1e1e; - flex-shrink: 0; -} - -.quickNavButton { - background: transparent; - border: 1px solid #3c4349; - border-radius: 4px; - color: #e5e6e6; - cursor: pointer; - font: inherit; - font-size: 13px; - padding: 6px 12px; -} - -.quickNavButton:hover { - background: #2c3338; - color: #fff; -} - /* Creation row - horizontal pills */ .creationRow { display: flex; @@ -315,12 +289,16 @@ display: flex; flex-direction: column; gap: 2px; + min-width: 0; } .siteRowName { font-size: clamp(14px, 1.4vw, 15px); font-weight: 500; color: #fff; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } .siteRowDate { @@ -332,6 +310,14 @@ margin-right: 12px; } +.siteRowActions { + display: flex; + align-items: center; + gap: 4px; + flex-shrink: 0; + padding-right: 8px; +} + .siteRowMenu button { color: #949494 !important; } @@ -349,6 +335,70 @@ background: rgba(220, 38, 38, 0.1) !important; } +.keepButton { + background: #3858e9; + border: 1px solid #6c84ff; + border-radius: 4px; + color: #fff; + cursor: pointer; + font: inherit; + font-size: 13px; + font-weight: 500; + line-height: 1; + padding: 8px 12px; + white-space: nowrap; +} + +.keepButton:hover { + background: #4f6df0; + border-color: #8fa1ff; +} + +.showMoreButton { + align-items: center; + background: transparent; + border: 1px solid #3c4349; + border-radius: 4px; + color: #dcdcde; + cursor: pointer; + display: inline-flex; + font: inherit; + font-size: 13px; + margin-top: 12px; + padding: 8px 12px; +} + +.showMoreButton:hover { + background: #2c3338; + color: #fff; +} + +.autosaveSummary { + align-items: center; + color: #9ca3af; + display: flex; + flex-wrap: wrap; + font-size: 13px; + gap: 8px; + margin-bottom: 12px; +} + +.autosaveBadge { + background: #2c3338; + border: 1px solid #3c4349; + border-radius: 999px; + color: #e5e6e6; + font-size: 12px; + font-weight: 500; + line-height: 1; + padding: 5px 8px; + white-space: nowrap; +} + +.autosaveHint { + color: #c3c4c7; +} + /* Loading state */ .loadingContainer { display: flex; @@ -547,6 +597,14 @@ padding: 12px 16px; } + .siteRowActions { + padding-right: 4px; + } + + .keepButton { + padding: 7px 10px; + } + .siteRowLogo { width: 36px; height: 36px; From 12c31e048c66b8cc56312ede19a7d7dbf0a5d83c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Tue, 19 May 2026 15:32:04 +0200 Subject: [PATCH 18/47] Refine saved playgrounds overlay sections --- .../saved-playgrounds-overlay/index.tsx | 179 +++++++++++------- 1 file changed, 110 insertions(+), 69 deletions(-) diff --git a/packages/playground/website/src/components/saved-playgrounds-overlay/index.tsx b/packages/playground/website/src/components/saved-playgrounds-overlay/index.tsx index 09412dab42a..17e77301bc4 100644 --- a/packages/playground/website/src/components/saved-playgrounds-overlay/index.tsx +++ b/packages/playground/website/src/components/saved-playgrounds-overlay/index.tsx @@ -362,6 +362,8 @@ export function SavedPlaygroundsOverlay({ : explicitlySavedSites.slice(0, MAX_VISIBLE_SAVED_SITES); const hiddenSavedSitesCount = explicitlySavedSites.length - visibleSavedSites.length; + const activeTemporarySite = + activeSite?.metadata.storage === 'none' ? activeSite : undefined; function formatSiteCreatedDate(site: SiteInfo) { return site.metadata.whenCreated @@ -469,6 +471,110 @@ export function SavedPlaygroundsOverlay({ ); } + function openSaveSiteModal() { + onClose(); + modalDispatch(setActiveModal(modalSlugs.SAVE_SITE)); + } + + function renderCurrentPlaygroundSection() { + if (!activeTemporarySite) { + return null; + } + + return ( + +
+
+ +
+ +
+
+
+
+ ); + } + + function renderSavedPlaygroundsSection() { + if (explicitlySavedSites.length === 0) { + return null; + } + + return ( + +
+ {visibleSavedSites.map(renderSiteRow)} +
+ {hiddenSavedSitesCount > 0 && ( + + )} +
+ ); + } + + function renderAutosavesSection() { + if (autosavedSites.length === 0) { + return null; + } + + return ( + +
+ + Last {MAX_AUTOSAVED_SITES} + + Auto-delete + + Use Save to keep one. + +
+
+ {autosavedSites.map(renderSiteRow)} +
+
+ ); + } + if (viewMode === 'blueprints') { return ( @@ -741,75 +847,10 @@ export function SavedPlaygroundsOverlay({ )}
- -
-
- -
- {visibleSavedSites.map(renderSiteRow)} -
- {hiddenSavedSitesCount > 0 && ( - - )} -
- - {autosavedSites.length > 0 && ( - -
- - Last {MAX_AUTOSAVED_SITES} - - - Auto-delete - - - Use Save to keep one. - -
-
- {autosavedSites.map(renderSiteRow)} -
-
- )} + {explicitlySavedSites.length === 0 && renderAutosavesSection()} + {renderCurrentPlaygroundSection()} + {renderSavedPlaygroundsSection()} + {explicitlySavedSites.length > 0 && renderAutosavesSection()} ); From f8c2f4c234b732b640ee31648450392aba7fed53 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Tue, 19 May 2026 15:54:44 +0200 Subject: [PATCH 19/47] Settle autosave status after root site creation --- .../website/playwright/e2e/website-ui.spec.ts | 13 +++-- .../saved-playgrounds-overlay/index.tsx | 49 +++++++++++-------- .../src/lib/state/redux/boot-site-client.ts | 9 ++++ 3 files changed, 46 insertions(+), 25 deletions(-) diff --git a/packages/playground/website/playwright/e2e/website-ui.spec.ts b/packages/playground/website/playwright/e2e/website-ui.spec.ts index 044ac3ee7f5..42e2fed354a 100644 --- a/packages/playground/website/playwright/e2e/website-ui.spec.ts +++ b/packages/playground/website/playwright/e2e/website-ui.spec.ts @@ -596,7 +596,7 @@ test.describe('Database panel', () => { // Test browser-saved Playgrounds by default and explicit temporary opt-outs. test.describe('Default Playground storage', () => { - test('should create a browser-autosaved Playground by default', async ({ + test('should create and finish autosaving a Playground from the root URL', async ({ website, browserName, }) => { @@ -620,10 +620,13 @@ test.describe('Default Playground storage', () => { 0 ); await expect( - website.page.getByText( - /Autosaved Playground|Autosaving|Finalizing autosave/ - ) - ).toBeVisible(); + website.page.getByText('Autosaved Playground') + ).toBeVisible({ + timeout: 120000, + }); + await expect( + website.page.getByText(/Autosaving|Finalizing autosave/) + ).toHaveCount(0); }); test('should start an unsaved Playground from the overlay opt-out', async ({ diff --git a/packages/playground/website/src/components/saved-playgrounds-overlay/index.tsx b/packages/playground/website/src/components/saved-playgrounds-overlay/index.tsx index 17e77301bc4..9502771f020 100644 --- a/packages/playground/website/src/components/saved-playgrounds-overlay/index.tsx +++ b/packages/playground/website/src/components/saved-playgrounds-overlay/index.tsx @@ -476,23 +476,27 @@ export function SavedPlaygroundsOverlay({ modalDispatch(setActiveModal(modalSlugs.SAVE_SITE)); } - function renderCurrentPlaygroundSection() { - if (!activeTemporarySite) { - return null; - } - + function renderTemporaryPlaygroundSection() { return ( - +
-
- -
+ {activeTemporarySite && ( +
+ +
+ )}
@@ -848,7 +857,7 @@ export function SavedPlaygroundsOverlay({
{explicitlySavedSites.length === 0 && renderAutosavesSection()} - {renderCurrentPlaygroundSection()} + {renderTemporaryPlaygroundSection()} {renderSavedPlaygroundsSection()} {explicitlySavedSites.length > 0 && renderAutosavesSection()} diff --git a/packages/playground/website/src/lib/state/redux/boot-site-client.ts b/packages/playground/website/src/lib/state/redux/boot-site-client.ts index 61ed06ffafe..01d64ad9a48 100644 --- a/packages/playground/website/src/lib/state/redux/boot-site-client.ts +++ b/packages/playground/website/src/lib/state/redux/boot-site-client.ts @@ -325,6 +325,10 @@ export function bootSiteClient( } if (shouldSyncNewOpfsSiteInBackground && mountDescriptor) { + // Progress callbacks cross worker and iframe boundaries asynchronously. + // A final progress message may arrive after mountOpfs() resolves and + // must not resurrect the completed sync state in Redux. + let opfsMountSettled = false; dispatch( updateClientInfo({ siteSlug: site.slug, @@ -340,6 +344,9 @@ export function bootSiteClient( initialSyncDirection: 'memfs-to-opfs', }, (progress: SyncProgress) => { + if (opfsMountSettled) { + return; + } dispatch( updateClientInfo({ siteSlug: site.slug, @@ -354,6 +361,7 @@ export function bootSiteClient( } ) .then(() => { + opfsMountSettled = true; dispatch( updateClientInfo({ siteSlug: site.slug, @@ -364,6 +372,7 @@ export function bootSiteClient( ); }) .catch((error: unknown) => { + opfsMountSettled = true; logger.error( 'Error syncing saved Playground to OPFS', error From 86926770cd9f3eac8cac5f3d2c0ebc93e1af43f2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Tue, 19 May 2026 18:12:09 +0200 Subject: [PATCH 20/47] Reorder saved playgrounds overlay sections --- .../src/components/saved-playgrounds-overlay/index.tsx | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/packages/playground/website/src/components/saved-playgrounds-overlay/index.tsx b/packages/playground/website/src/components/saved-playgrounds-overlay/index.tsx index 9502771f020..cf9ecd76c7e 100644 --- a/packages/playground/website/src/components/saved-playgrounds-overlay/index.tsx +++ b/packages/playground/website/src/components/saved-playgrounds-overlay/index.tsx @@ -482,7 +482,7 @@ export function SavedPlaygroundsOverlay({ title={ activeTemporarySite ? 'Current Playground' - : 'Start without autosave' + : 'New Playground' } >
@@ -856,10 +856,9 @@ export function SavedPlaygroundsOverlay({ )} - {explicitlySavedSites.length === 0 && renderAutosavesSection()} - {renderTemporaryPlaygroundSection()} + {renderAutosavesSection()} {renderSavedPlaygroundsSection()} - {explicitlySavedSites.length > 0 && renderAutosavesSection()} + {renderTemporaryPlaygroundSection()} ); From 38bf19d28738a70dd8bf1334af3436adbf539e3e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Tue, 19 May 2026 18:21:00 +0200 Subject: [PATCH 21/47] Move unsaved playground action into creation buttons --- .../website/playwright/e2e/opfs.spec.ts | 21 +++-- .../website/playwright/e2e/website-ui.spec.ts | 3 +- .../saved-playgrounds-overlay/index.tsx | 79 +++---------------- 3 files changed, 19 insertions(+), 84 deletions(-) diff --git a/packages/playground/website/playwright/e2e/opfs.spec.ts b/packages/playground/website/playwright/e2e/opfs.spec.ts index 9206dce86f1..5b90dbd57f5 100644 --- a/packages/playground/website/playwright/e2e/opfs.spec.ts +++ b/packages/playground/website/playwright/e2e/opfs.spec.ts @@ -99,10 +99,9 @@ test('should switch between sites', async ({ website, browserName }) => { // Open the saved playgrounds overlay to switch sites await website.openSavedPlaygroundsOverlay(); - // The unsaved row is an explicit opt-out from saved-by-default. + // The unsaved button is an explicit opt-out from saved-by-default. await website.page - .locator('[class*="siteRowContent"]') - .filter({ hasText: 'Unsaved Playground' }) + .getByRole('button', { name: 'Unsaved Playground' }) .click(); await website.ensureSiteManagerIsOpen(); @@ -174,10 +173,9 @@ test('should preserve PHP constants when saving a temporary site to OPFS', async // Open the saved playgrounds overlay to switch sites await website.openSavedPlaygroundsOverlay(); - // Use the unsaved row to create another default Playground, then switch back. + // Use the unsaved button to create a temporary Playground, then switch back. await website.page - .locator('[class*="siteRowContent"]') - .filter({ hasText: 'Unsaved Playground' }) + .getByRole('button', { name: 'Unsaved Playground' }) .click(); // Open the overlay again to switch back to the stored site @@ -622,14 +620,13 @@ test('should create a saved site when importing ZIP while on a saved site with n // Open the saved playgrounds overlay await website.openSavedPlaygroundsOverlay(); - // The unsaved row is available as the affordance for creating another + // The unsaved button is available as the affordance for creating another // Playground. - const tempPlaygroundRow = website.page - .locator('[class*="siteRowContent"]') - .filter({ hasText: 'Unsaved Playground' }); + const tempPlaygroundButton = website.page.getByRole('button', { + name: 'Unsaved Playground', + }); - // The row exists but creates a new saved Playground by default. - await expect(tempPlaygroundRow).toBeVisible(); + await expect(tempPlaygroundButton).toBeVisible(); // Create a test ZIP const importedMarker = 'FRESH_IMPORT_MARKER_BBBBB'; diff --git a/packages/playground/website/playwright/e2e/website-ui.spec.ts b/packages/playground/website/playwright/e2e/website-ui.spec.ts index 42e2fed354a..abe84991f20 100644 --- a/packages/playground/website/playwright/e2e/website-ui.spec.ts +++ b/packages/playground/website/playwright/e2e/website-ui.spec.ts @@ -641,8 +641,7 @@ test.describe('Default Playground storage', () => { await website.goto('./'); await website.openSavedPlaygroundsOverlay(); await website.page - .locator('[class*="siteRowContent"]') - .filter({ hasText: 'Unsaved Playground' }) + .getByRole('button', { name: 'Unsaved Playground' }) .click(); await website.waitForNestedIframes(); await website.ensureSiteManagerIsClosed(); diff --git a/packages/playground/website/src/components/saved-playgrounds-overlay/index.tsx b/packages/playground/website/src/components/saved-playgrounds-overlay/index.tsx index cf9ecd76c7e..487149ecccf 100644 --- a/packages/playground/website/src/components/saved-playgrounds-overlay/index.tsx +++ b/packages/playground/website/src/components/saved-playgrounds-overlay/index.tsx @@ -6,7 +6,7 @@ import { MenuGroup, MenuItem, } from '@wordpress/components'; -import { moreVertical, upload, link } from '@wordpress/icons'; +import { cautionFilled, moreVertical, upload, link } from '@wordpress/icons'; import { Icon } from '@wordpress/icons'; import { GitHubIcon } from '../../github/github'; import { useDispatch } from 'react-redux'; @@ -304,11 +304,18 @@ export function SavedPlaygroundsOverlay({ const creationOptions = [ { id: 'vanilla', - title: 'Vanilla WordPress', + title: 'New Playground', iconComponent: , onClick: createVanillaSite, disabled: false, }, + { + id: 'temporary', + title: 'Unsaved Playground', + icon: cautionFilled, + onClick: onTemporaryPlaygroundClick, + disabled: false, + }, { id: 'wp-pr', title: 'WordPress PR', @@ -362,8 +369,6 @@ export function SavedPlaygroundsOverlay({ : explicitlySavedSites.slice(0, MAX_VISIBLE_SAVED_SITES); const hiddenSavedSitesCount = explicitlySavedSites.length - visibleSavedSites.length; - const activeTemporarySite = - activeSite?.metadata.storage === 'none' ? activeSite : undefined; function formatSiteCreatedDate(site: SiteInfo) { return site.metadata.whenCreated @@ -471,71 +476,6 @@ export function SavedPlaygroundsOverlay({ ); } - function openSaveSiteModal() { - onClose(); - modalDispatch(setActiveModal(modalSlugs.SAVE_SITE)); - } - - function renderTemporaryPlaygroundSection() { - return ( - -
-
- - {activeTemporarySite && ( -
- -
- )} -
-
-
- ); - } - function renderSavedPlaygroundsSection() { if (explicitlySavedSites.length === 0) { return null; @@ -858,7 +798,6 @@ export function SavedPlaygroundsOverlay({ {renderAutosavesSection()} {renderSavedPlaygroundsSection()} - {renderTemporaryPlaygroundSection()} ); From d176750a3f2c8651df6aeecbcee2cd61f935aec8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Tue, 19 May 2026 18:27:16 +0200 Subject: [PATCH 22/47] Use plus icon for new Playground action --- .../saved-playgrounds-overlay/index.tsx | 62 ++++++++++++------- .../style.module.css | 10 +++ 2 files changed, 51 insertions(+), 21 deletions(-) diff --git a/packages/playground/website/src/components/saved-playgrounds-overlay/index.tsx b/packages/playground/website/src/components/saved-playgrounds-overlay/index.tsx index 487149ecccf..65322c37153 100644 --- a/packages/playground/website/src/components/saved-playgrounds-overlay/index.tsx +++ b/packages/playground/website/src/components/saved-playgrounds-overlay/index.tsx @@ -6,7 +6,7 @@ import { MenuGroup, MenuItem, } from '@wordpress/components'; -import { cautionFilled, moreVertical, upload, link } from '@wordpress/icons'; +import { moreVertical, plus, upload, link } from '@wordpress/icons'; import { Icon } from '@wordpress/icons'; import { GitHubIcon } from '../../github/github'; import { useDispatch } from 'react-redux'; @@ -305,14 +305,14 @@ export function SavedPlaygroundsOverlay({ { id: 'vanilla', title: 'New Playground', - iconComponent: , + icon: plus, + iconSize: 40, onClick: createVanillaSite, disabled: false, }, { id: 'temporary', title: 'Unsaved Playground', - icon: cautionFilled, onClick: onTemporaryPlaygroundClick, disabled: false, }, @@ -709,25 +709,45 @@ export function SavedPlaygroundsOverlay({
- {creationOptions.map((option) => ( - - ))} + + {option.title} + + + ); + })}
diff --git a/packages/playground/website/src/components/saved-playgrounds-overlay/style.module.css b/packages/playground/website/src/components/saved-playgrounds-overlay/style.module.css index a5e9ae29338..165463af114 100644 --- a/packages/playground/website/src/components/saved-playgrounds-overlay/style.module.css +++ b/packages/playground/website/src/components/saved-playgrounds-overlay/style.module.css @@ -126,6 +126,16 @@ height: 28px; } +.newPlaygroundIcon { + width: 42px; + height: 42px; +} + +.newPlaygroundIcon :global(svg) { + width: 40px; + height: 40px; +} + .creationTitle { font-size: clamp(13px, 1.3vw, 14px); font-weight: 500; From 635a756c1d52c95320c45ff66c7be19e6e73d24f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Tue, 19 May 2026 20:56:37 +0200 Subject: [PATCH 23/47] Remove unsaved overlay action --- .../website/playwright/e2e/opfs.spec.ts | 35 ++++++++---------- .../website/playwright/e2e/website-ui.spec.ts | 37 +++++++++++++------ .../saved-playgrounds-overlay/index.tsx | 28 +++----------- 3 files changed, 46 insertions(+), 54 deletions(-) diff --git a/packages/playground/website/playwright/e2e/opfs.spec.ts b/packages/playground/website/playwright/e2e/opfs.spec.ts index 5b90dbd57f5..7ea6815795d 100644 --- a/packages/playground/website/playwright/e2e/opfs.spec.ts +++ b/packages/playground/website/playwright/e2e/opfs.spec.ts @@ -99,21 +99,20 @@ test('should switch between sites', async ({ website, browserName }) => { // Open the saved playgrounds overlay to switch sites await website.openSavedPlaygroundsOverlay(); - // The unsaved button is an explicit opt-out from saved-by-default. - await website.page - .getByRole('button', { name: 'Unsaved Playground' }) - .click(); + // Start another saved Playground, then switch back to the first one. + await website.page.getByRole('button', { name: 'New Playground' }).click(); + await website.waitForNestedIframes(); await website.ensureSiteManagerIsOpen(); await expect(website.page.getByLabel('Playground title')).not.toContainText( firstSiteName ); - await expect(website.page.getByLabel('Playground title')).toContainText( - 'Unsaved Playground' - ); - expect(new URL(website.page.url()).searchParams.get('storage')).toBe( - 'temp' - ); + await expect( + website.page.getByText('Autosaved in this browser') + ).toBeVisible({ timeout: 120000 }); + await expect + .poll(() => new URL(website.page.url()).searchParams.get('site-slug')) + .toBeTruthy(); await website.openSavedPlaygroundsOverlay(); await website.page @@ -173,10 +172,9 @@ test('should preserve PHP constants when saving a temporary site to OPFS', async // Open the saved playgrounds overlay to switch sites await website.openSavedPlaygroundsOverlay(); - // Use the unsaved button to create a temporary Playground, then switch back. - await website.page - .getByRole('button', { name: 'Unsaved Playground' }) - .click(); + // Create another Playground, then switch back. + await website.page.getByRole('button', { name: 'New Playground' }).click(); + await website.waitForNestedIframes(); // Open the overlay again to switch back to the stored site await website.openSavedPlaygroundsOverlay(); @@ -620,13 +618,10 @@ test('should create a saved site when importing ZIP while on a saved site with n // Open the saved playgrounds overlay await website.openSavedPlaygroundsOverlay(); - // The unsaved button is available as the affordance for creating another - // Playground. - const tempPlaygroundButton = website.page.getByRole('button', { - name: 'Unsaved Playground', + const importZipButton = website.page.getByRole('button', { + name: 'Import a .zip', }); - - await expect(tempPlaygroundButton).toBeVisible(); + await expect(importZipButton).toBeVisible(); // Create a test ZIP const importedMarker = 'FRESH_IMPORT_MARKER_BBBBB'; diff --git a/packages/playground/website/playwright/e2e/website-ui.spec.ts b/packages/playground/website/playwright/e2e/website-ui.spec.ts index abe84991f20..1c9fe47a92b 100644 --- a/packages/playground/website/playwright/e2e/website-ui.spec.ts +++ b/packages/playground/website/playwright/e2e/website-ui.spec.ts @@ -629,7 +629,7 @@ test.describe('Default Playground storage', () => { ).toHaveCount(0); }); - test('should start an unsaved Playground from the overlay opt-out', async ({ + test('should show intent-driven creation actions in the overlay', async ({ website, browserName, }) => { @@ -640,18 +640,33 @@ test.describe('Default Playground storage', () => { await website.goto('./'); await website.openSavedPlaygroundsOverlay(); - await website.page - .getByRole('button', { name: 'Unsaved Playground' }) - .click(); - await website.waitForNestedIframes(); - await website.ensureSiteManagerIsClosed(); - - const url = new URL(website.page.url()); - expect(url.searchParams.get('storage')).toBe('temp'); - expect(url.searchParams.get('site-slug')).toBe(null); await expect( - website.page.getByText('Unsaved Playground') + website.page.getByRole('button', { name: 'New Playground' }) + ).toBeVisible(); + await expect( + website.page.getByRole('button', { + name: 'Preview a WordPress PR', + }) + ).toBeVisible(); + await expect( + website.page.getByRole('button', { + name: 'Preview a Gutenberg PR', + }) + ).toBeVisible(); + await expect( + website.page.getByRole('button', { name: 'Import from GitHub' }) + ).toBeVisible(); + await expect( + website.page.getByRole('button', { + name: 'Open a Blueprint URL', + }) ).toBeVisible(); + await expect( + website.page.getByRole('button', { name: 'Import a .zip' }) + ).toBeVisible(); + await expect( + website.page.getByRole('button', { name: 'Unsaved Playground' }) + ).toHaveCount(0); }); test('should show autosave browser storage details in the Site Manager by default', async ({ diff --git a/packages/playground/website/src/components/saved-playgrounds-overlay/index.tsx b/packages/playground/website/src/components/saved-playgrounds-overlay/index.tsx index 65322c37153..eeed1c69906 100644 --- a/packages/playground/website/src/components/saved-playgrounds-overlay/index.tsx +++ b/packages/playground/website/src/components/saved-playgrounds-overlay/index.tsx @@ -236,18 +236,6 @@ export function SavedPlaygroundsOverlay({ onClose(); }; - const onTemporaryPlaygroundClick = async () => { - if (temporarySite) { - await sitesAPI.setActiveSite(temporarySite.slug); - dispatch(setSiteManagerSection('site-details')); - onClose(); - } else { - dispatch(setSiteManagerOpen(false)); - redirectTo(PlaygroundRoute.newTemporarySite()); - onClose(); - } - }; - const getLogoDataURL = (logo: SiteLogo): string => { return `data:${logo.mime};base64,${logo.data}`; }; @@ -310,15 +298,9 @@ export function SavedPlaygroundsOverlay({ onClick: createVanillaSite, disabled: false, }, - { - id: 'temporary', - title: 'Unsaved Playground', - onClick: onTemporaryPlaygroundClick, - disabled: false, - }, { id: 'wp-pr', - title: 'WordPress PR', + title: 'Preview a WordPress PR', iconComponent: , onClick: () => { modalDispatch(setActiveModal(modalSlugs.PREVIEW_PR_WP)); @@ -327,7 +309,7 @@ export function SavedPlaygroundsOverlay({ }, { id: 'gutenberg-pr', - title: 'Gutenberg PR', + title: 'Preview a Gutenberg PR', iconComponent: , onClick: () => { modalDispatch(setActiveModal(modalSlugs.PREVIEW_PR_GUTENBERG)); @@ -336,7 +318,7 @@ export function SavedPlaygroundsOverlay({ }, { id: 'github', - title: 'From GitHub', + title: 'Import from GitHub', iconComponent: GitHubIcon, onClick: async () => { await createSiteForImport(); @@ -346,7 +328,7 @@ export function SavedPlaygroundsOverlay({ }, { id: 'blueprint-url', - title: 'Blueprint URL', + title: 'Open a Blueprint URL', icon: link, onClick: () => { modalDispatch(setActiveModal(modalSlugs.BLUEPRINT_URL)); @@ -355,7 +337,7 @@ export function SavedPlaygroundsOverlay({ }, { id: 'zip', - title: 'Import .zip', + title: 'Import a .zip', icon: upload, onClick: () => { zipFileInputRef.current?.click(); From 4dd3797f8c8087ce537fced456bb427bd3ae6b99 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Wed, 20 May 2026 02:42:55 +0200 Subject: [PATCH 24/47] Move save status actions into popovers --- .../website/playwright/e2e/website-ui.spec.ts | 55 +++++---- .../save-status-indicator.module.css | 93 +++++++++++---- .../browser-chrome/save-status-indicator.tsx | 107 +++++++++++++----- .../saved-playgrounds-overlay/index.tsx | 6 +- .../site-manager/site-info-panel/index.tsx | 2 +- 5 files changed, 188 insertions(+), 75 deletions(-) diff --git a/packages/playground/website/playwright/e2e/website-ui.spec.ts b/packages/playground/website/playwright/e2e/website-ui.spec.ts index 1c9fe47a92b..080bfeb2fbc 100644 --- a/packages/playground/website/playwright/e2e/website-ui.spec.ts +++ b/packages/playground/website/playwright/e2e/website-ui.spec.ts @@ -616,17 +616,17 @@ test.describe('Default Playground storage', () => { new URL(website.page.url()).searchParams.get('site-slug') ) .toBeTruthy(); - await expect(website.page.getByText('Unsaved Playground')).toHaveCount( - 0 - ); await expect( - website.page.getByText('Autosaved Playground') + website.page.getByRole('button', { name: 'Autosaved' }) ).toBeVisible({ timeout: 120000, }); await expect( website.page.getByText(/Autosaving|Finalizing autosave/) ).toHaveCount(0); + await expect( + website.page.getByRole('button', { name: 'Unsaved' }) + ).toHaveCount(0); }); test('should show intent-driven creation actions in the overlay', async ({ @@ -693,7 +693,7 @@ test.describe('Default Playground storage', () => { 'section[class*="site-info-panel"]' ); await expect( - siteInfoPanel.getByRole('button', { name: 'Save Playground' }) + siteInfoPanel.getByRole('button', { name: 'Store permanently' }) ).toBeVisible(); await expect( website.page.getByText( @@ -713,11 +713,14 @@ test.describe('Default Playground storage', () => { await website.goto('./'); await website.ensureSiteManagerIsClosed(); - const keepButton = website.page.getByRole('button', { - name: 'Save Playground', + const statusButton = website.page.getByRole('button', { + name: 'Autosaved', }); - await expect(keepButton).toBeVisible({ timeout: 120000 }); - await keepButton.click(); + await expect(statusButton).toBeVisible({ timeout: 120000 }); + await statusButton.click(); + await website.page + .getByRole('button', { name: 'Store permanently' }) + .click(); await expect .poll(() => @@ -729,15 +732,13 @@ test.describe('Default Playground storage', () => { ) .toBe('explicit'); await expect( - website.page.getByText( - /Autosaved Playground|Autosaving|Finalizing autosave/ - ) + website.page.getByText(/Autosaved|Autosaving|Finalizing autosave/) ).toHaveCount(0); await expect( website.page.getByText(/Saved Playground|Saving|Finalizing save/) ).toBeVisible(); await expect( - website.page.getByText('Autosaved Playground') + website.page.getByRole('button', { name: 'Autosaved' }) ).toHaveCount(0); }); @@ -762,7 +763,7 @@ test.describe('Default Playground storage', () => { expect(siteSlug).toBeTruthy(); await expect( - website.page.getByText('Autosaved Playground') + website.page.getByRole('button', { name: 'Autosaved' }) ).toBeVisible({ timeout: 120000 }); const expectedBlogName = `Saved Playground ${Date.now()}`; @@ -813,19 +814,33 @@ echo get_option('blogname'); null ); await expect( - website.page.getByText('Unsaved Playground') + website.page.getByRole('button', { name: 'Unsaved' }) ).toBeVisible(); }); - test('should show "Unsaved Playground" status for storage=temp Playgrounds', async ({ + test('should show "Unsaved" status for storage=temp Playgrounds', async ({ website, }) => { await website.goto('./?storage=temp'); await website.ensureSiteManagerIsClosed(); - const indicator = website.page.getByText('Unsaved Playground'); + const indicator = website.page.getByRole('button', { + name: 'Unsaved', + }); await expect(indicator).toBeVisible(); await expect(indicator).toHaveCount(1); + await indicator.click(); + await expect( + website.page.getByText( + 'This Playground is not stored anywhere. Changes are lost when this page is refreshed or closed.' + ) + ).toBeVisible(); + await website.page + .getByRole('button', { name: 'Store permanently' }) + .click(); + await expect( + website.page.getByRole('dialog', { name: 'Save Playground' }) + ).toBeVisible(); expect(new URL(website.page.url()).searchParams.get('storage')).toBe( 'temp' ); @@ -845,13 +860,15 @@ echo get_option('blogname'); await expect(indicator).toHaveCount(1); }); - test('should not show "Unsaved Playground" status when "can-save=no" is set', async ({ + test('should not show "Unsaved" status when "can-save=no" is set', async ({ website, }) => { await website.goto('./?can-save=no'); await website.ensureSiteManagerIsClosed(); - const indicator = website.page.getByText('Unsaved Playground'); + const indicator = website.page.getByRole('button', { + name: 'Unsaved', + }); await expect(indicator).toHaveCount(0); }); diff --git a/packages/playground/website/src/components/browser-chrome/save-status-indicator.module.css b/packages/playground/website/src/components/browser-chrome/save-status-indicator.module.css index 3ce356cfa2a..6aa334d36bd 100644 --- a/packages/playground/website/src/components/browser-chrome/save-status-indicator.module.css +++ b/packages/playground/website/src/components/browser-chrome/save-status-indicator.module.css @@ -2,7 +2,7 @@ display: flex; align-items: center; gap: 6px; - padding: 4px 0; + padding: 5px 8px; border-radius: 4px; font-size: 14px; font-weight: 500; @@ -33,30 +33,22 @@ fill: #93c5fd; } -.unsaved { - color: #fcd34d; +.actionable { + cursor: pointer; + transition: background 0.15s ease; } -.unsaved svg { - fill: #fcd34d; +.actionable:hover, +.actionable[aria-expanded='true'] { + background: rgba(255, 255, 255, 0.08); } -.saveButton { - margin-left: 2px; - padding: 4px 10px; - background: #fcd34d; - color: #1e2327; - border: none; - border-radius: 4px; - font-size: 13px; - font-weight: 600; - cursor: pointer; - transition: background 0.15s ease; - min-height: 28px; +.unsaved { + color: #fcd34d; } -.saveButton:hover { - background: #fde68a; +.unsaved svg { + fill: #fcd34d; } .saving { @@ -81,6 +73,63 @@ display: inline; } +.popover { + z-index: 100000; +} + +.popover :global(.components-popover__content) { + background: #fff; + border: 1px solid #dcdcde; + border-radius: 6px; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.18); + color: #1e1e1e; + padding: 0; + width: min(320px, calc(100vw - 24px)); +} + +.popover-content { + display: grid; + gap: 10px; + padding: 14px; +} + +.popover-title { + font-size: 14px; + font-weight: 600; + line-height: 1.3; +} + +.popover-description { + color: #50575e; + font-size: 13px; + line-height: 1.45; + margin: 0; + white-space: normal; +} + +.primary-action { + align-items: center; + background: #1e1e1e; + border: none; + border-radius: 4px; + color: #fff; + cursor: pointer; + display: inline-flex; + font-size: 13px; + font-weight: 600; + justify-content: center; + line-height: 1.2; + min-height: 34px; + padding: 8px 12px; + transition: background 0.15s ease; + width: fit-content; +} + +.primary-action:hover, +.primary-action:focus-visible { + background: #2f2f2f; +} + .spinner { width: 16px; height: 16px; @@ -102,10 +151,4 @@ .indicator { font-size: 13px; } - - .saveButton { - min-width: 40px; - min-height: 40px; - padding: 8px 12px; - } } diff --git a/packages/playground/website/src/components/browser-chrome/save-status-indicator.tsx b/packages/playground/website/src/components/browser-chrome/save-status-indicator.tsx index 019f6f7513f..c432f955179 100644 --- a/packages/playground/website/src/components/browser-chrome/save-status-indicator.tsx +++ b/packages/playground/website/src/components/browser-chrome/save-status-indicator.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { useRef, useState } from 'react'; import css from './save-status-indicator.module.css'; import classNames from 'classnames'; import { @@ -8,7 +8,7 @@ import { useAppDispatch, } from '../../lib/state/redux/store'; import { modalSlugs, setActiveModal } from '../../lib/state/redux/slice-ui'; -import { Icon } from '@wordpress/components'; +import { Icon, Popover } from '@wordpress/components'; import { backup, check, cautionFilled } from '@wordpress/icons'; import { isAutosavedSite, @@ -64,16 +64,20 @@ export function SaveStatusIndicator() { const clientInfo = useAppSelector(getActiveClientInfo); const activeSite = useActiveSite(); const dispatch = useAppDispatch(); + const statusButtonRef = useRef(null); + const [isPopoverOpen, setIsPopoverOpen] = useState(false); const opfsSync = clientInfo?.opfsSync; const status = getSaveStatus(activeSite, opfsSync); const isAutosaved = activeSite ? isAutosavedSite(activeSite) : false; const handleSaveClick = () => { + setIsPopoverOpen(false); dispatch(setActiveModal(modalSlugs.SAVE_SITE)); }; const handleKeepClick = () => { + setIsPopoverOpen(false); if (activeSite) { void dispatch(preserveSite(activeSite.slug)); } @@ -90,18 +94,48 @@ export function SaveStatusIndicator() { if (status === 'autosaved') { return ( -
- - Autosaved Playground + <> -
+ {isPopoverOpen && ( + setIsPopoverOpen(false)} + anchor={statusButtonRef.current} + focusOnMount="firstElement" + className={css.popover} + > +
+
Autosaved
+

+ This Playground is saved in this browser with + your recent autosaves. It will be deleted after + 5 newer autosaves unless you store it + permanently. +

+ +
+
+ )} + ); } @@ -114,16 +148,6 @@ export function SaveStatusIndicator() { {getSyncLabel({ isAutosaved, progress })} - {isAutosaved && ( - - )}
); } @@ -145,16 +169,45 @@ export function SaveStatusIndicator() { // Unsaved - temporary playground that will be lost on refresh return ( -
- - Unsaved Playground + <> -
+ {isPopoverOpen && ( + setIsPopoverOpen(false)} + anchor={statusButtonRef.current} + focusOnMount="firstElement" + className={css.popover} + > +
+
Unsaved
+

+ This Playground is not stored anywhere. Changes are + lost when this page is refreshed or closed. +

+ +
+
+ )} + ); } diff --git a/packages/playground/website/src/components/saved-playgrounds-overlay/index.tsx b/packages/playground/website/src/components/saved-playgrounds-overlay/index.tsx index eeed1c69906..ca958dc7cad 100644 --- a/packages/playground/website/src/components/saved-playgrounds-overlay/index.tsx +++ b/packages/playground/website/src/components/saved-playgrounds-overlay/index.tsx @@ -407,9 +407,9 @@ export function SavedPlaygroundsOverlay({ type="button" className={css.keepButton} onClick={() => handleKeepSite(site)} - title="Save this Playground so it is not pruned from recent autosaves." + title="Store this Playground permanently so it is not pruned from recent autosaves." > - Save + Store permanently )} - Save Playground + Store permanently )} )} From 3f610ebefc64a6f5c692ffa211d3200dc81f9e39 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Wed, 20 May 2026 02:47:06 +0200 Subject: [PATCH 25/47] Clarify autosaves section --- .../saved-playgrounds-overlay/index.tsx | 16 +++------ .../style.module.css | 34 +++---------------- 2 files changed, 9 insertions(+), 41 deletions(-) diff --git a/packages/playground/website/src/components/saved-playgrounds-overlay/index.tsx b/packages/playground/website/src/components/saved-playgrounds-overlay/index.tsx index ca958dc7cad..b8e8ac8bb90 100644 --- a/packages/playground/website/src/components/saved-playgrounds-overlay/index.tsx +++ b/packages/playground/website/src/components/saved-playgrounds-overlay/index.tsx @@ -294,7 +294,7 @@ export function SavedPlaygroundsOverlay({ id: 'vanilla', title: 'New Playground', icon: plus, - iconSize: 40, + iconSize: 56, onClick: createVanillaSite, disabled: false, }, @@ -489,16 +489,10 @@ export function SavedPlaygroundsOverlay({ } return ( - -
- - Last {MAX_AUTOSAVED_SITES} - - Auto-delete - - Use Save to keep one. - -
+
{autosavedSites.map(renderSiteRow)}
diff --git a/packages/playground/website/src/components/saved-playgrounds-overlay/style.module.css b/packages/playground/website/src/components/saved-playgrounds-overlay/style.module.css index 165463af114..91a975b81be 100644 --- a/packages/playground/website/src/components/saved-playgrounds-overlay/style.module.css +++ b/packages/playground/website/src/components/saved-playgrounds-overlay/style.module.css @@ -127,13 +127,13 @@ } .newPlaygroundIcon { - width: 42px; - height: 42px; + width: 58px; + height: 58px; } .newPlaygroundIcon :global(svg) { - width: 40px; - height: 40px; + width: 56px; + height: 56px; } .creationTitle { @@ -383,32 +383,6 @@ color: #fff; } -.autosaveSummary { - align-items: center; - color: #9ca3af; - display: flex; - flex-wrap: wrap; - font-size: 13px; - gap: 8px; - margin-bottom: 12px; -} - -.autosaveBadge { - background: #2c3338; - border: 1px solid #3c4349; - border-radius: 999px; - color: #e5e6e6; - font-size: 12px; - font-weight: 500; - line-height: 1; - padding: 5px 8px; - white-space: nowrap; -} - -.autosaveHint { - color: #c3c4c7; -} - /* Loading state */ .loadingContainer { display: flex; From c7c1931ce93c316020220c9bc8f18be88ba5c61e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Wed, 20 May 2026 03:08:41 +0200 Subject: [PATCH 26/47] Open GitHub import modal before creating site --- .../website/playwright/e2e/website-ui.spec.ts | 13 ++++++++ .../website/src/components/layout/index.tsx | 8 ++++- .../saved-playgrounds-overlay/index.tsx | 7 ++-- .../style.module.css | 4 +-- .../src/github/github-import-form/form.tsx | 8 ++++- .../src/github/github-import-form/modal.tsx | 33 +++++++++++++++++-- .../website/src/lib/state/redux/slice-ui.ts | 1 + 7 files changed, 65 insertions(+), 9 deletions(-) diff --git a/packages/playground/website/playwright/e2e/website-ui.spec.ts b/packages/playground/website/playwright/e2e/website-ui.spec.ts index 080bfeb2fbc..5e4c491427c 100644 --- a/packages/playground/website/playwright/e2e/website-ui.spec.ts +++ b/packages/playground/website/playwright/e2e/website-ui.spec.ts @@ -639,6 +639,9 @@ test.describe('Default Playground storage', () => { ); await website.goto('./'); + const siteSlugBeforeGitHubImport = new URL( + website.page.url() + ).searchParams.get('site-slug'); await website.openSavedPlaygroundsOverlay(); await expect( website.page.getByRole('button', { name: 'New Playground' }) @@ -667,6 +670,16 @@ test.describe('Default Playground storage', () => { await expect( website.page.getByRole('button', { name: 'Unsaved Playground' }) ).toHaveCount(0); + + await website.page + .getByRole('button', { name: 'Import from GitHub' }) + .click(); + await expect( + website.page.getByRole('dialog', { name: 'Import from GitHub' }) + ).toBeVisible(); + expect(new URL(website.page.url()).searchParams.get('site-slug')).toBe( + siteSlugBeforeGitHubImport + ); }); test('should show autosave browser storage details in the Site Manager by default', async ({ diff --git a/packages/playground/website/src/components/layout/index.tsx b/packages/playground/website/src/components/layout/index.tsx index 76c8429df18..118e242e273 100644 --- a/packages/playground/website/src/components/layout/index.tsx +++ b/packages/playground/website/src/components/layout/index.tsx @@ -188,10 +188,16 @@ function Modals() { ); - } else if (currentModal === modalSlugs.GITHUB_IMPORT) { + } else if ( + currentModal === modalSlugs.GITHUB_IMPORT || + currentModal === modalSlugs.GITHUB_IMPORT_NEW_SITE + ) { return ( { - await createSiteForImport(); - modalDispatch(setActiveModal(modalSlugs.GITHUB_IMPORT)); + onClick: () => { + modalDispatch( + setActiveModal(modalSlugs.GITHUB_IMPORT_NEW_SITE) + ); }, disabled: offline, }, diff --git a/packages/playground/website/src/components/saved-playgrounds-overlay/style.module.css b/packages/playground/website/src/components/saved-playgrounds-overlay/style.module.css index 91a975b81be..38dde1756f6 100644 --- a/packages/playground/website/src/components/saved-playgrounds-overlay/style.module.css +++ b/packages/playground/website/src/components/saved-playgrounds-overlay/style.module.css @@ -116,8 +116,8 @@ display: flex; align-items: center; justify-content: center; - width: 32px; - height: 32px; + width: 58px; + height: 58px; } .creationIcon :global(svg) { diff --git a/packages/playground/website/src/github/github-import-form/form.tsx b/packages/playground/website/src/github/github-import-form/form.tsx index 5d6c7538ca1..3da191028c6 100644 --- a/packages/playground/website/src/github/github-import-form/form.tsx +++ b/packages/playground/website/src/github/github-import-form/form.tsx @@ -20,6 +20,7 @@ import { logger } from '@php-wasm/logger'; export interface GitHubImportFormProps { playground: PlaygroundClient; + getPlaygroundBeforeImport?: () => Promise; onImported: (details: { url: string; urlInformation: GitHubURLInformation; @@ -42,6 +43,7 @@ function getClient() { export default function GitHubImportForm({ playground, + getPlaygroundBeforeImport, onImported, }: GitHubImportFormProps) { const [errors, setErrors] = useState>({}); @@ -140,13 +142,17 @@ export default function GitHubImportForm({ setImportProgress({ ...progress }), } ); + const targetPlayground = getPlaygroundBeforeImport + ? await getPlaygroundBeforeImport() + : playground; await importFromGitHub( - playground, + targetPlayground, ghFiles, contentType!, relativeRepoPath, pluginOrThemeName ); + targetPlayground.goTo('/'); onImported({ url: newUrl, urlInformation: urlInformation!, diff --git a/packages/playground/website/src/github/github-import-form/modal.tsx b/packages/playground/website/src/github/github-import-form/modal.tsx index 93398c67473..d25c3f50113 100644 --- a/packages/playground/website/src/github/github-import-form/modal.tsx +++ b/packages/playground/website/src/github/github-import-form/modal.tsx @@ -2,31 +2,60 @@ import type { GitHubImportFormProps } from './form'; import GitHubImportForm from './form'; import { usePlaygroundClient } from '../../lib/use-playground-client'; import { setActiveModal } from '../../lib/state/redux/slice-ui'; -import type { PlaygroundDispatch } from '../../lib/state/redux/store'; +import { selectTemporarySite } from '../../lib/state/redux/slice-sites'; +import { + type PlaygroundDispatch, + useAppSelector, +} from '../../lib/state/redux/store'; +import { useSitesAPI } from '../../lib/state/redux/site-management-api-middleware'; import { useDispatch } from 'react-redux'; import { Modal } from '../../components/modal'; interface GithubImportModalProps { defaultOpen?: boolean; + createNewSiteBeforeImport?: boolean; onImported?: GitHubImportFormProps['onImported']; } export function GithubImportModal({ defaultOpen, + createNewSiteBeforeImport, onImported, }: GithubImportModalProps) { const dispatch: PlaygroundDispatch = useDispatch(); const playground = usePlaygroundClient(); + const sitesAPI = useSitesAPI(); + const temporarySite = useAppSelector(selectTemporarySite); const closeModal = () => { dispatch(setActiveModal(null)); }; + + const createSiteForImport = async () => { + try { + await sitesAPI.createNewSavedSite(); + } catch { + if (temporarySite) { + await sitesAPI.setActiveSite(temporarySite.slug); + } else { + await sitesAPI.createNewTemporarySite(); + } + } + const client = sitesAPI.getClient(); + if (!client) { + throw new Error('No active Playground to import into.'); + } + return client; + }; + return ( { - playground!.goTo('/'); // eslint-disable-next-line no-alert alert( 'Import finished! Your Playground site has been updated.' 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 bd7588ec778..1468a55f563 100644 --- a/packages/playground/website/src/lib/state/redux/slice-ui.ts +++ b/packages/playground/website/src/lib/state/redux/slice-ui.ts @@ -24,6 +24,7 @@ export const modalSlugs = { ERROR_REPORT: 'error-report', START_ERROR: 'start-error', GITHUB_IMPORT: 'github-import', + GITHUB_IMPORT_NEW_SITE: 'github-import-new-site', GITHUB_EXPORT: 'github-export', GITHUB_PRIVATE_REPO_AUTH: 'github-private-repo-auth', PREVIEW_PR_WP: 'preview-pr-wordpress', From 69fbb0adac290befd567fc76b2f94fd1db1092bc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Thu, 21 May 2026 15:33:33 +0200 Subject: [PATCH 27/47] Prompt to restore matching autosaves --- .../website/playwright/e2e/website-ui.spec.ts | 107 ++++++++++++--- .../ensure-playground-site-is-selected.tsx | 122 +++++++++++++++++- .../redux/site-management-api-middleware.ts | 25 +++- .../src/lib/state/redux/slice-sites.ts | 11 ++ .../website/src/lib/state/redux/store.ts | 7 +- .../src/lib/state/url/setup-url.spec.ts | 36 ++++++ .../website/src/lib/state/url/setup-url.ts | 93 +++++++++++++ 7 files changed, 371 insertions(+), 30 deletions(-) create mode 100644 packages/playground/website/src/lib/state/url/setup-url.spec.ts create mode 100644 packages/playground/website/src/lib/state/url/setup-url.ts diff --git a/packages/playground/website/playwright/e2e/website-ui.spec.ts b/packages/playground/website/playwright/e2e/website-ui.spec.ts index 5e4c491427c..ea879e29ce6 100644 --- a/packages/playground/website/playwright/e2e/website-ui.spec.ts +++ b/packages/playground/website/playwright/e2e/website-ui.spec.ts @@ -9,6 +9,17 @@ 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'; +function getUniqueSavedPlaygroundSetupUrl( + label: string, + params: Record = {} +) { + const searchParams = new URLSearchParams({ + name: `${label}-${Date.now()}-${Math.random().toString(36).slice(2)}`, + ...params, + }); + return `./?${searchParams}`; +} + test('should reflect the URL update from the navigation bar in the WordPress site', async ({ website, }) => { @@ -596,6 +607,8 @@ test.describe('Database panel', () => { // Test browser-saved Playgrounds by default and explicit temporary opt-outs. test.describe('Default Playground storage', () => { + test.describe.configure({ mode: 'serial' }); + test('should create and finish autosaving a Playground from the root URL', async ({ website, browserName, @@ -611,16 +624,14 @@ test.describe('Default Playground storage', () => { ).toBeVisible(); await website.ensureSiteManagerIsClosed(); - await expect - .poll(() => - new URL(website.page.url()).searchParams.get('site-slug') - ) - .toBeTruthy(); await expect( website.page.getByRole('button', { name: 'Autosaved' }) ).toBeVisible({ timeout: 120000, }); + expect(new URL(website.page.url()).searchParams.get('site-slug')).toBe( + null + ); await expect( website.page.getByText(/Autosaving|Finalizing autosave/) ).toHaveCount(0); @@ -638,7 +649,9 @@ test.describe('Default Playground storage', () => { `Saved-by-default Playgrounds rely on OPFS, which is not available in Playwright's ${browserName}.` ); - await website.goto('./'); + await website.goto( + getUniqueSavedPlaygroundSetupUrl('creation-actions') + ); const siteSlugBeforeGitHubImport = new URL( website.page.url() ).searchParams.get('site-slug'); @@ -691,7 +704,7 @@ test.describe('Default Playground storage', () => { `Saved-by-default Playgrounds rely on OPFS, which is not available in Playwright's ${browserName}.` ); - await website.goto('./'); + await website.goto(getUniqueSavedPlaygroundSetupUrl('storage-details')); await website.ensureSiteManagerIsOpen(); await expect( @@ -724,7 +737,7 @@ test.describe('Default Playground storage', () => { `Saved-by-default Playgrounds rely on OPFS, which is not available in Playwright's ${browserName}.` ); - await website.goto('./'); + await website.goto(getUniqueSavedPlaygroundSetupUrl('promote')); await website.ensureSiteManagerIsClosed(); const statusButton = website.page.getByRole('button', { name: 'Autosaved', @@ -764,22 +777,74 @@ test.describe('Default Playground storage', () => { `Saved-by-default Playgrounds rely on OPFS, which is not available in Playwright's ${browserName}.` ); - await website.goto('./'); + await website.goto(getUniqueSavedPlaygroundSetupUrl('restore')); + expect(new URL(website.page.url()).searchParams.get('site-slug')).toBe( + null + ); + + await expect( + website.page.getByRole('button', { name: 'Autosaved' }) + ).toBeVisible({ timeout: 120000 }); + + const expectedBlogName = `Saved Playground ${Date.now()}`; + await website.page.evaluate(async (blogName) => { + const playground = (window as any).playground; + await playground.run({ + code: ` new URL(website.page.url()).searchParams.get('site-slug') ) .toBeTruthy(); - const siteSlug = new URL(website.page.url()).searchParams.get( - 'site-slug' + + const blogName = await website.page.evaluate(async () => { + const playground = (window as any).playground; + const result = await playground.run({ + code: ` { + test.skip( + browserName !== 'chromium', + `Saved-by-default Playgrounds rely on OPFS, which is not available in Playwright's ${browserName}.` ); - expect(siteSlug).toBeTruthy(); + const setupName = `fresh-${Date.now()}-${Math.random() + .toString(36) + .slice(2)}`; + await website.goto(`./?php=8.3&name=${setupName}&random=first`); await expect( website.page.getByRole('button', { name: 'Autosaved' }) ).toBeVisible({ timeout: 120000 }); - const expectedBlogName = `Saved Playground ${Date.now()}`; + const firstBlogName = `Restored Playground ${Date.now()}`; await website.page.evaluate(async (blogName) => { const playground = (window as any).playground; await playground.run({ @@ -789,15 +854,21 @@ update_option('blogname', ${JSON.stringify(blogName)}); `, }); await playground.flushOpfs('/wordpress'); - }, expectedBlogName); + }, firstBlogName); - await website.page.reload(); + await website.page.goto(`./?php=8.3&name=${setupName}&cb=cache-buster`); + await expect( + website.page.getByRole('dialog', { + name: 'Restore autosaved Playground?', + }) + ).toBeVisible(); + await website.page.getByRole('button', { name: 'Start fresh' }).click(); await website.waitForNestedIframes(); expect(new URL(website.page.url()).searchParams.get('site-slug')).toBe( - siteSlug + null ); - const blogName = await website.page.evaluate(async () => { + const freshBlogName = await website.page.evaluate(async () => { const playground = (window as any).playground; const result = await playground.run({ code: ` state.sites.opfsSitesLoadingState ); const activeSite = useAppSelector((state) => selectActiveSite(state)); + const sortedSites = useAppSelector(selectSortedSites); const dispatch = useAppDispatch(); const sitesAPI = useSitesAPI(); const url = useCurrentUrl(); @@ -55,6 +66,17 @@ export function EnsurePlaygroundSiteIsSelected({ ); const [needMissingSitePromptForSlug, setNeedMissingSitePromptForSlug] = useState(false); + const [autosavePrompt, setAutosavePrompt] = useState<{ + site: SiteInfo; + setupUrlFingerprint: string; + }>(); + const [freshSetupFingerprints, setFreshSetupFingerprints] = useState< + string[] + >([]); + const currentSetupUrlFingerprint = useMemo( + () => getSetupUrlFingerprint(url), + [url.href] + ); const prevUrl = usePrevious(url); @@ -139,8 +161,28 @@ export function EnsurePlaygroundSiteIsSelected({ if (shouldUseTemporarySite) { await sitesAPI.createNewTemporarySite(); } else { + const matchingAutosave = sortedSites + .filter(isAutosavedSite) + .find( + (site) => + getSetupUrlFingerprintFromSite(site) === + currentSetupUrlFingerprint + ); + if ( + matchingAutosave && + !freshSetupFingerprints.includes(currentSetupUrlFingerprint) + ) { + setAutosavePrompt({ + site: matchingAutosave, + setupUrlFingerprint: currentSetupUrlFingerprint, + }); + return; + } + try { - await sitesAPI.createNewSavedSite(); + await sitesAPI.createNewSavedSite(undefined, undefined, { + updateUrl: false, + }); } catch (error) { logger.error( 'Error creating saved site. Falling back to a temporary site.', @@ -153,7 +195,12 @@ export function EnsurePlaygroundSiteIsSelected({ ensureSiteIsSelected(); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [url.href, requestedSiteSlug, siteListingStatus]); + }, [ + url.href, + requestedSiteSlug, + siteListingStatus, + freshSetupFingerprints, + ]); useEffect(() => { if ( @@ -178,5 +225,74 @@ export function EnsurePlaygroundSiteIsSelected({ } }, [url.searchParams]); + if (autosavePrompt) { + return ( + { + void sitesAPI.setActiveSite(autosavePrompt.site.slug); + setAutosavePrompt(undefined); + }} + onStartFresh={() => { + setFreshSetupFingerprints((fingerprints) => [ + ...fingerprints, + autosavePrompt.setupUrlFingerprint, + ]); + setAutosavePrompt(undefined); + }} + /> + ); + } + return children; } + +function RestoreAutosavePrompt({ + site, + onRestore, + onStartFresh, +}: { + site: SiteInfo; + onRestore: () => void; + onStartFresh: () => void; +}) { + const lastUsed = new Date( + (site.metadata.whenLastUsed ?? + site.metadata.whenCreated ?? + Date.now()) - 2 + ); + + return ( + +

+ You have an autosaved Playground from{' '} + {getRelativeDate(lastUsed)}. +

+

Restore it, or start fresh from this setup URL.

+ + + + + + + + +
+ ); +} diff --git a/packages/playground/website/src/lib/state/redux/site-management-api-middleware.ts b/packages/playground/website/src/lib/state/redux/site-management-api-middleware.ts index a50d82310ed..713ba6ae842 100644 --- a/packages/playground/website/src/lib/state/redux/site-management-api-middleware.ts +++ b/packages/playground/website/src/lib/state/redux/site-management-api-middleware.ts @@ -17,6 +17,7 @@ import { setStoredSiteSpec, deriveSiteNameFromSlug, isAutosavedSite, + type SitePersistence, } from './slice-sites'; import { randomSiteName } from './random-site-name'; import { persistTemporarySite } from './persist-temporary-site'; @@ -131,7 +132,10 @@ export interface PlaygroundSitesAPI { * @param siteSlug The slug of the site to activate. * @throws When the site is not found or fails to boot. */ - setActiveSite(siteSlug: string): Promise; + setActiveSite( + siteSlug: string, + options?: { updateUrl?: boolean } + ): Promise; /** * Creates a new temporary site and boots it. @@ -156,7 +160,11 @@ export interface PlaygroundSitesAPI { */ createNewSavedSite( siteSlug?: string, - settings?: SiteSettings + settings?: SiteSettings, + options?: { + persistence?: SitePersistence; + updateUrl?: boolean; + } ): Promise; } @@ -341,7 +349,7 @@ export function createSitesAPI( await dispatch(removeSite(siteSlug)); }, - async setActiveSite(siteSlug: string) { + async setActiveSite(siteSlug: string, options = {}) { const state = getState(); const site = selectSiteBySlug(state, siteSlug); if (!site) { @@ -376,7 +384,7 @@ export function createSitesAPI( }, }); }); - dispatch(setActiveSite(siteSlug)); + dispatch(setActiveSite(siteSlug, options)); await bootPromise; }, @@ -420,7 +428,8 @@ export function createSitesAPI( async createNewSavedSite( requestedSiteSlug?: string, - settings?: SiteSettings + settings?: SiteSettings, + options = {} ) { if (!isOpfsAvailable) { throw new Error( @@ -433,10 +442,12 @@ export function createSitesAPI( const url = getUrlWithSettings(settings); const newSiteInfo = await dispatch( setStoredSiteSpec(siteName, url, requestedSiteSlug, { - persistence: 'autosave', + persistence: options.persistence ?? 'autosave', }) ); - await api.setActiveSite(newSiteInfo.slug); + await api.setActiveSite(newSiteInfo.slug, { + updateUrl: options.updateUrl, + }); await dispatch( pruneAutosavedSites({ excludeSlugs: [newSiteInfo.slug], diff --git a/packages/playground/website/src/lib/state/redux/slice-sites.ts b/packages/playground/website/src/lib/state/redux/slice-sites.ts index b76ea8b0b43..c120032e298 100644 --- a/packages/playground/website/src/lib/state/redux/slice-sites.ts +++ b/packages/playground/website/src/lib/state/redux/slice-sites.ts @@ -32,6 +32,7 @@ import { type AutosavedSitesPruneOptions, type SitePersistence, } from './site-lifecycle'; +import { getSetupUrlFingerprint } from '../url/setup-url'; export { MAX_AUTOSAVED_SITES, SitePersistenceTypes, @@ -342,6 +343,9 @@ export function setTemporarySiteSpec( id: crypto.randomUUID(), whenCreated: Date.now(), storage: 'none' as const, + sourceSetupUrlFingerprint: getSetupUrlFingerprint( + playgroundUrlWithQueryApiArgs + ), originalBlueprint: {}, originalBlueprintSource: { type: 'none', @@ -445,6 +449,9 @@ export function setTemporarySiteSpec( id: crypto.randomUUID(), whenCreated: Date.now(), storage: 'none' as const, + sourceSetupUrlFingerprint: getSetupUrlFingerprint( + playgroundUrlWithQueryApiArgs + ), originalBlueprint: resolvedBlueprint.blueprint, originalBlueprintSource: resolvedBlueprint.source!, runtimeConfiguration: await resolveRuntimeConfiguration( @@ -509,6 +516,9 @@ export function setStoredSiteSpec( whenLastUsed: now, persistence: options.persistence ?? 'explicit', storage: 'opfs' as const, + sourceSetupUrlFingerprint: getSetupUrlFingerprint( + playgroundUrlWithQueryApiArgs + ), originalBlueprint: resolvedBlueprint.blueprint, originalBlueprintSource: resolvedBlueprint.source!, runtimeConfiguration: await resolveRuntimeConfiguration( @@ -597,6 +607,7 @@ export interface SiteMetadata { * compatibility with existing saved Playgrounds. */ persistence?: SitePersistence; + sourceSetupUrlFingerprint?: string; // @TODO: Accept any string as a php version? runtimeConfiguration: RuntimeConfiguration; diff --git a/packages/playground/website/src/lib/state/redux/store.ts b/packages/playground/website/src/lib/state/redux/store.ts index ff39751a049..8e1be740fb9 100644 --- a/packages/playground/website/src/lib/state/redux/store.ts +++ b/packages/playground/website/src/lib/state/redux/store.ts @@ -99,7 +99,10 @@ export const selectActiveSiteErrorDetails = ( export const useActiveSite = () => useAppSelector(selectActiveSite); -export const setActiveSite = (slug: string | undefined) => { +export const setActiveSite = ( + slug: string | undefined, + options: { updateUrl?: boolean } = {} +) => { return ( dispatch: PlaygroundDispatch, getState: () => PlaygroundReduxState @@ -110,7 +113,7 @@ export const setActiveSite = (slug: string | undefined) => { return; } dispatch(__internal_uiSlice.actions.setActiveSite(slug)); - if (slug) { + if (slug && options.updateUrl !== false) { const site = selectSiteBySlug(getState(), slug); redirectTo(PlaygroundRoute.site(site)); } diff --git a/packages/playground/website/src/lib/state/url/setup-url.spec.ts b/packages/playground/website/src/lib/state/url/setup-url.spec.ts new file mode 100644 index 00000000000..8724e34da46 --- /dev/null +++ b/packages/playground/website/src/lib/state/url/setup-url.spec.ts @@ -0,0 +1,36 @@ +import { getSetupUrlFingerprint } from './setup-url'; + +describe('getSetupUrlFingerprint', () => { + it('ignores runtime, UI, and cache-busting parameters', () => { + const first = getSetupUrlFingerprint( + new URL( + 'https://playground.test/?php=8.3&wp=6.8&random=abc&modal=save-site&site-slug=demo&_=1&cacheBustWhatever=1#' + ) + ); + const second = getSetupUrlFingerprint( + new URL('https://playground.test/?wp=6.8&php=8.3&cb=2&ts=3&v=4') + ); + + expect(first).toBe(second); + }); + + it('keeps setup-affecting parameters distinct', () => { + expect( + getSetupUrlFingerprint( + new URL('https://playground.test/?php=8.3&wp=6.8') + ) + ).not.toBe( + getSetupUrlFingerprint( + new URL('https://playground.test/?php=8.4&wp=6.8') + ) + ); + }); + + it('includes the blueprint fragment', () => { + expect( + getSetupUrlFingerprint(new URL('https://playground.test/#one')) + ).not.toBe( + getSetupUrlFingerprint(new URL('https://playground.test/#two')) + ); + }); +}); diff --git a/packages/playground/website/src/lib/state/url/setup-url.ts b/packages/playground/website/src/lib/state/url/setup-url.ts new file mode 100644 index 00000000000..b57723b2133 --- /dev/null +++ b/packages/playground/website/src/lib/state/url/setup-url.ts @@ -0,0 +1,93 @@ +import type { SiteInfo } from '../redux/slice-sites'; + +const SETUP_QUERY_PARAMS = new Set([ + 'blueprint', + 'blueprint-url', + 'core-pr', + 'gutenberg-branch', + 'gutenberg-pr', + 'import-content', + 'import-site', + 'import-wxr', + 'language', + 'login', + 'multisite', + 'name', + 'networking', + 'php', + 'plugin', + 'theme', + 'url', + 'wp', +]); + +export function getSetupUrlFingerprint(url: URL) { + return getSetupUrlFingerprintFromParts({ + searchParams: url.searchParams, + hash: url.hash, + }); +} + +export function getSetupUrlFingerprintFromSite(site: SiteInfo) { + return ( + site.metadata.sourceSetupUrlFingerprint || + getSetupUrlFingerprintFromParts({ + searchParams: site.originalUrlParams?.searchParams, + hash: site.originalUrlParams?.hash, + }) + ); +} + +function getSetupUrlFingerprintFromParts({ + searchParams, + hash, +}: { + searchParams?: URLSearchParams | Record; + hash?: string; +}) { + const normalizedParams = normalizeSetupSearchParams(searchParams); + const normalizedHash = normalizeHash(hash); + return JSON.stringify({ + search: normalizedParams, + hash: normalizedHash, + }); +} + +function normalizeSetupSearchParams( + searchParams?: URLSearchParams | Record +) { + const params = new URLSearchParams(); + if (searchParams instanceof URLSearchParams) { + for (const key of searchParams.keys()) { + if (!SETUP_QUERY_PARAMS.has(key)) { + continue; + } + for (const value of searchParams.getAll(key)) { + params.append(key, value); + } + } + } else if (searchParams) { + for (const [key, value] of Object.entries(searchParams)) { + if (!SETUP_QUERY_PARAMS.has(key)) { + continue; + } + const values = Array.isArray(value) ? value : [value]; + for (const item of values) { + params.append(key, item); + } + } + } + + return Array.from(params.entries()) + .map(([key, value]) => [key, value] as const) + .sort(([keyA, valueA], [keyB, valueB]) => { + if (keyA === keyB) { + return valueA.localeCompare(valueB); + } + return keyA.localeCompare(keyB); + }); +} + +function normalizeHash(hash?: string) { + return hash?.replace(/^#/, '') || ''; +} From 51ffbf559daf05724b32f0b878d311d3133200c2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Thu, 21 May 2026 15:50:27 +0200 Subject: [PATCH 28/47] Show recent autosave restore as nudge --- .../website/playwright/e2e/website-ui.spec.ts | 18 ++- .../ensure-playground-site-is-selected.tsx | 113 ++++++++---------- .../restore-autosave-nudge.module.css | 54 +++++++++ .../src/lib/state/redux/site-lifecycle.ts | 12 ++ .../redux/site-management-api-middleware.ts | 6 +- .../src/lib/state/redux/slice-sites.spec.ts | 30 ++++- .../src/lib/state/redux/slice-sites.ts | 1 + 7 files changed, 162 insertions(+), 72 deletions(-) create mode 100644 packages/playground/website/src/components/ensure-playground-site/restore-autosave-nudge.module.css diff --git a/packages/playground/website/playwright/e2e/website-ui.spec.ts b/packages/playground/website/playwright/e2e/website-ui.spec.ts index ea879e29ce6..eff017f010f 100644 --- a/packages/playground/website/playwright/e2e/website-ui.spec.ts +++ b/packages/playground/website/playwright/e2e/website-ui.spec.ts @@ -800,13 +800,13 @@ update_option('blogname', ${JSON.stringify(blogName)}); await website.page.reload(); await expect( - website.page.getByRole('dialog', { - name: 'Restore autosaved Playground?', - }) + website.page.getByText('Recent autosave available') ).toBeVisible(); - await website.page - .getByRole('button', { name: 'Restore autosave' }) - .click(); + await expect( + website.page.getByText('A new Playground is already starting.') + ).toBeVisible(); + await website.waitForNestedIframes(); + await website.page.getByRole('button', { name: 'Restore' }).click(); await website.waitForNestedIframes(); await expect .poll(() => @@ -858,12 +858,10 @@ update_option('blogname', ${JSON.stringify(blogName)}); await website.page.goto(`./?php=8.3&name=${setupName}&cb=cache-buster`); await expect( - website.page.getByRole('dialog', { - name: 'Restore autosaved Playground?', - }) + website.page.getByText('Recent autosave available') ).toBeVisible(); - await website.page.getByRole('button', { name: 'Start fresh' }).click(); await website.waitForNestedIframes(); + await website.page.getByRole('button', { name: 'Keep new' }).click(); expect(new URL(website.page.url()).searchParams.get('site-slug')).toBe( null ); diff --git a/packages/playground/website/src/components/ensure-playground-site/ensure-playground-site-is-selected.tsx b/packages/playground/website/src/components/ensure-playground-site/ensure-playground-site-is-selected.tsx index 8f97bd4248a..db9fd1c6b50 100644 --- a/packages/playground/website/src/components/ensure-playground-site/ensure-playground-site-is-selected.tsx +++ b/packages/playground/website/src/components/ensure-playground-site/ensure-playground-site-is-selected.tsx @@ -1,5 +1,6 @@ import { useEffect, useMemo, useState } from 'react'; -import { Button, Flex, FlexItem } from '@wordpress/components'; +import { Button } from '@wordpress/components'; +import css from './restore-autosave-nudge.module.css'; import { useCurrentUrl } from '../../lib/state/url/router-hooks'; import { isSaveDisabledByQueryParam, @@ -12,6 +13,7 @@ import { selectSiteBySlug, selectSortedSites, type SiteInfo, + wasSiteRecentlyInteractedWith, } from '../../lib/state/redux/slice-sites'; import { selectActiveSite, @@ -27,7 +29,6 @@ import { getSetupUrlFingerprint, getSetupUrlFingerprintFromSite, } from '../../lib/state/url/setup-url'; -import { Modal } from '../modal'; import { getRelativeDate } from '../../lib/get-relative-date'; /** @@ -66,7 +67,7 @@ export function EnsurePlaygroundSiteIsSelected({ ); const [needMissingSitePromptForSlug, setNeedMissingSitePromptForSlug] = useState(false); - const [autosavePrompt, setAutosavePrompt] = useState<{ + const [autosaveNudge, setAutosaveNudge] = useState<{ site: SiteInfo; setupUrlFingerprint: string; }>(); @@ -170,18 +171,23 @@ export function EnsurePlaygroundSiteIsSelected({ ); if ( matchingAutosave && - !freshSetupFingerprints.includes(currentSetupUrlFingerprint) + !freshSetupFingerprints.includes( + currentSetupUrlFingerprint + ) && + wasSiteRecentlyInteractedWith(matchingAutosave) ) { - setAutosavePrompt({ + setAutosaveNudge({ site: matchingAutosave, setupUrlFingerprint: currentSetupUrlFingerprint, }); - return; } try { await sitesAPI.createNewSavedSite(undefined, undefined, { updateUrl: false, + excludeFromPruning: matchingAutosave + ? [matchingAutosave.slug] + : [], }); } catch (error) { logger.error( @@ -225,36 +231,37 @@ export function EnsurePlaygroundSiteIsSelected({ } }, [url.searchParams]); - if (autosavePrompt) { - return ( - { - void sitesAPI.setActiveSite(autosavePrompt.site.slug); - setAutosavePrompt(undefined); - }} - onStartFresh={() => { - setFreshSetupFingerprints((fingerprints) => [ - ...fingerprints, - autosavePrompt.setupUrlFingerprint, - ]); - setAutosavePrompt(undefined); - }} - /> - ); - } - - return children; + return ( + <> + {children} + {autosaveNudge && ( + { + void sitesAPI.setActiveSite(autosaveNudge.site.slug); + setAutosaveNudge(undefined); + }} + onKeepNew={() => { + setFreshSetupFingerprints((fingerprints) => [ + ...fingerprints, + autosaveNudge.setupUrlFingerprint, + ]); + setAutosaveNudge(undefined); + }} + /> + )} + + ); } -function RestoreAutosavePrompt({ +function RestoreAutosaveNudge({ site, onRestore, - onStartFresh, + onKeepNew, }: { site: SiteInfo; onRestore: () => void; - onStartFresh: () => void; + onKeepNew: () => void; }) { const lastUsed = new Date( (site.metadata.whenLastUsed ?? @@ -263,36 +270,22 @@ function RestoreAutosavePrompt({ ); return ( - -

- You have an autosaved Playground from{' '} - {getRelativeDate(lastUsed)}. -

-

Restore it, or start fresh from this setup URL.

- - - - - - - - -
+ ); } diff --git a/packages/playground/website/src/components/ensure-playground-site/restore-autosave-nudge.module.css b/packages/playground/website/src/components/ensure-playground-site/restore-autosave-nudge.module.css new file mode 100644 index 00000000000..359370b2c86 --- /dev/null +++ b/packages/playground/website/src/components/ensure-playground-site/restore-autosave-nudge.module.css @@ -0,0 +1,54 @@ +.nudge { + position: fixed; + z-index: 30; + top: 62px; + right: 16px; + display: flex; + align-items: center; + gap: 16px; + max-width: min(520px, calc(100vw - 32px)); + padding: 12px; + background: #fff; + border: 1px solid #dcdcde; + border-radius: 6px; + box-shadow: 0 8px 24px rgb(0 0 0 / 14%); + color: #1e1e1e; +} + +.copy { + min-width: 0; +} + +.title { + font-size: 13px; + font-weight: 600; + line-height: 18px; +} + +.description { + margin-top: 2px; + color: #50575e; + font-size: 12px; + line-height: 17px; +} + +.actions { + display: flex; + flex: 0 0 auto; + align-items: center; + gap: 6px; +} + +@media (max-width: 600px) { + .nudge { + top: 108px; + left: 12px; + right: 12px; + align-items: stretch; + flex-direction: column; + } + + .actions { + justify-content: flex-end; + } +} diff --git a/packages/playground/website/src/lib/state/redux/site-lifecycle.ts b/packages/playground/website/src/lib/state/redux/site-lifecycle.ts index 9d69d83926c..9b9cc4eba5c 100644 --- a/packages/playground/website/src/lib/state/redux/site-lifecycle.ts +++ b/packages/playground/website/src/lib/state/redux/site-lifecycle.ts @@ -1,6 +1,7 @@ import type { SiteInfo } from './slice-sites'; export const MAX_AUTOSAVED_SITES = 5; +export const RECENT_AUTOSAVE_RESTORE_WINDOW_MS = 15 * 60 * 1000; export const SitePersistenceTypes = ['autosave', 'explicit'] as const; export type SitePersistence = (typeof SitePersistenceTypes)[number]; @@ -14,6 +15,17 @@ export function getSiteRecencyTimestamp(site: SiteInfo) { return site.metadata.whenLastUsed ?? site.metadata.whenCreated ?? 0; } +export function wasSiteRecentlyInteractedWith( + site: SiteInfo, + now = Date.now() +) { + const recencyTimestamp = getSiteRecencyTimestamp(site); + return ( + recencyTimestamp > 0 && + now - recencyTimestamp <= RECENT_AUTOSAVE_RESTORE_WINDOW_MS + ); +} + export function isAutosavedSite(site: SiteInfo) { return ( site.metadata.storage === 'opfs' && diff --git a/packages/playground/website/src/lib/state/redux/site-management-api-middleware.ts b/packages/playground/website/src/lib/state/redux/site-management-api-middleware.ts index 713ba6ae842..3e9413897e5 100644 --- a/packages/playground/website/src/lib/state/redux/site-management-api-middleware.ts +++ b/packages/playground/website/src/lib/state/redux/site-management-api-middleware.ts @@ -164,6 +164,7 @@ export interface PlaygroundSitesAPI { options?: { persistence?: SitePersistence; updateUrl?: boolean; + excludeFromPruning?: string[]; } ): Promise; } @@ -450,7 +451,10 @@ export function createSitesAPI( }); await dispatch( pruneAutosavedSites({ - excludeSlugs: [newSiteInfo.slug], + excludeSlugs: [ + newSiteInfo.slug, + ...(options.excludeFromPruning ?? []), + ], }) ); return newSiteInfo.slug; diff --git a/packages/playground/website/src/lib/state/redux/slice-sites.spec.ts b/packages/playground/website/src/lib/state/redux/slice-sites.spec.ts index 36aa6380d55..a4a5490a964 100644 --- a/packages/playground/website/src/lib/state/redux/slice-sites.spec.ts +++ b/packages/playground/website/src/lib/state/redux/slice-sites.spec.ts @@ -1,4 +1,9 @@ -import { getAutosavedSitesToPrune, isAutosavedSite } from './site-lifecycle'; +import { + RECENT_AUTOSAVE_RESTORE_WINDOW_MS, + getAutosavedSitesToPrune, + isAutosavedSite, + wasSiteRecentlyInteractedWith, +} from './site-lifecycle'; import type { SiteInfo } from './slice-sites'; import { getUniqueSiteSlug, normalizeSiteSlug } from './site-slug'; @@ -103,6 +108,29 @@ describe('autosaved site helpers', () => { }).map((site) => site.slug) ).toEqual(['new-1']); }); + + it('only treats recently interacted-with sites as restore candidates', () => { + const now = Date.now(); + + expect( + wasSiteRecentlyInteractedWith( + createSite('recent', { + persistence: 'autosave', + whenLastUsed: now - RECENT_AUTOSAVE_RESTORE_WINDOW_MS + 1, + }), + now + ) + ).toBe(true); + expect( + wasSiteRecentlyInteractedWith( + createSite('stale', { + persistence: 'autosave', + whenLastUsed: now - RECENT_AUTOSAVE_RESTORE_WINDOW_MS - 1, + }), + now + ) + ).toBe(false); + }); }); function createSite( diff --git a/packages/playground/website/src/lib/state/redux/slice-sites.ts b/packages/playground/website/src/lib/state/redux/slice-sites.ts index c120032e298..322d4dbb1a9 100644 --- a/packages/playground/website/src/lib/state/redux/slice-sites.ts +++ b/packages/playground/website/src/lib/state/redux/slice-sites.ts @@ -40,6 +40,7 @@ export { getSiteRecencyTimestamp, isAutosavedSite, isExplicitlySavedSite, + wasSiteRecentlyInteractedWith, } from './site-lifecycle'; export type { AutosavedSitesPruneOptions, From bfe15b8ad2a425e3f082f50dc22e7b3a681938be Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Thu, 21 May 2026 17:23:52 +0200 Subject: [PATCH 29/47] Delay autosaving restore nudge fresh sites --- .../website/playwright/e2e/website-ui.spec.ts | 94 ++++++++++++++----- .../ensure-playground-site-is-selected.tsx | 9 +- .../lib/state/redux/persist-temporary-site.ts | 28 ++++-- .../redux/site-management-api-middleware.ts | 46 +++++++++ 4 files changed, 143 insertions(+), 34 deletions(-) diff --git a/packages/playground/website/playwright/e2e/website-ui.spec.ts b/packages/playground/website/playwright/e2e/website-ui.spec.ts index eff017f010f..457f6508e48 100644 --- a/packages/playground/website/playwright/e2e/website-ui.spec.ts +++ b/packages/playground/website/playwright/e2e/website-ui.spec.ts @@ -20,6 +20,34 @@ function getUniqueSavedPlaygroundSetupUrl( return `./?${searchParams}`; } +async function runPHPAndFlushOpfs(page: any, code: string) { + await expect + .poll( + () => + page.evaluate(async (phpCode: string) => { + try { + const playground = (window as any).playground; + await playground.run({ code: phpCode }); + await playground.flushOpfs('/wordpress'); + return 'ok'; + } catch (error) { + return String( + error instanceof Error ? error.message : error + ); + } + }, code), + { timeout: 120000 } + ) + .toBe('ok'); +} + +function updateBlogNameCode(blogName: string) { + return ` { @@ -787,16 +815,10 @@ test.describe('Default Playground storage', () => { ).toBeVisible({ timeout: 120000 }); const expectedBlogName = `Saved Playground ${Date.now()}`; - await website.page.evaluate(async (blogName) => { - const playground = (window as any).playground; - await playground.run({ - code: ` { - const playground = (window as any).playground; - await playground.run({ - code: ` + website.page.evaluate(() => { + const activeSite = (window as any).playgroundSites + .list() + .find((site: any) => site.isActive); + return { + storage: activeSite?.storage, + persistence: activeSite?.persistence, + }; + }) + ) + .toEqual({ storage: 'temporary', persistence: 'explicit' }); const freshBlogName = await website.page.evaluate(async () => { const playground = (window as any).playground; @@ -877,6 +911,24 @@ echo get_option('blogname'); return result.text; }); expect(freshBlogName).not.toBe(firstBlogName); + + await website.page.getByRole('button', { name: 'Keep new' }).click(); + await expect( + website.page.getByRole('button', { name: 'Autosaved' }) + ).toBeVisible({ timeout: 120000 }); + await expect + .poll(() => + website.page.evaluate(() => { + const activeSite = (window as any).playgroundSites + .list() + .find((site: any) => site.isActive); + return { + storage: activeSite?.storage, + persistence: activeSite?.persistence, + }; + }) + ) + .toEqual({ storage: 'opfs', persistence: 'autosave' }); }); test('should fall back to an unsaved Playground when browser storage is unavailable', async ({ diff --git a/packages/playground/website/src/components/ensure-playground-site/ensure-playground-site-is-selected.tsx b/packages/playground/website/src/components/ensure-playground-site/ensure-playground-site-is-selected.tsx index db9fd1c6b50..246ffba396a 100644 --- a/packages/playground/website/src/components/ensure-playground-site/ensure-playground-site-is-selected.tsx +++ b/packages/playground/website/src/components/ensure-playground-site/ensure-playground-site-is-selected.tsx @@ -180,14 +180,13 @@ export function EnsurePlaygroundSiteIsSelected({ site: matchingAutosave, setupUrlFingerprint: currentSetupUrlFingerprint, }); + await sitesAPI.createNewTemporarySite(); + return; } try { await sitesAPI.createNewSavedSite(undefined, undefined, { updateUrl: false, - excludeFromPruning: matchingAutosave - ? [matchingAutosave.slug] - : [], }); } catch (error) { logger.error( @@ -246,6 +245,10 @@ export function EnsurePlaygroundSiteIsSelected({ ...fingerprints, autosaveNudge.setupUrlFingerprint, ]); + void sitesAPI.autosaveTemporarySite(undefined, { + updateUrl: false, + excludeFromPruning: [autosaveNudge.site.slug], + }); setAutosaveNudge(undefined); }} /> diff --git a/packages/playground/website/src/lib/state/redux/persist-temporary-site.ts b/packages/playground/website/src/lib/state/redux/persist-temporary-site.ts index ebfc366fa6b..01b9d3ed392 100644 --- a/packages/playground/website/src/lib/state/redux/persist-temporary-site.ts +++ b/packages/playground/website/src/lib/state/redux/persist-temporary-site.ts @@ -16,6 +16,7 @@ import type store from './store'; import { selectClientBySiteSlug, updateClientInfo } from './slice-clients'; import { selectSiteBySlug, + type SitePersistence, updateSite, updateSiteMetadata, } from './slice-sites'; @@ -30,6 +31,9 @@ export function persistTemporarySite( localFsHandle?: FileSystemDirectoryHandle; siteName?: string; skipRenameModal?: boolean; + persistence?: SitePersistence; + updateUrl?: boolean; + keepOriginalUrlParams?: boolean; } = {} ) { return async ( @@ -210,21 +214,23 @@ export function persistTemporarySite( throw error; } - await dispatch( - updateSite({ - slug: siteSlug, - changes: { - originalUrlParams: undefined, - }, - }) - ); + if (!options.keepOriginalUrlParams) { + await dispatch( + updateSite({ + slug: siteSlug, + changes: { + originalUrlParams: undefined, + }, + }) + ); + } await dispatch( updateSiteMetadata({ slug: siteSlug, changes: { storage: storageType, - persistence: 'explicit', + persistence: options.persistence ?? 'explicit', // Reset the created date. Mental model: From the perspective of // the storage backend, the site was just created. whenCreated: Date.now(), @@ -260,7 +266,9 @@ export function persistTemporarySite( const updatedState = getState(); const updatedSite = selectSiteBySlug(updatedState, siteSlug); const persistentSiteUrl = PlaygroundRoute.site(updatedSite!); - redirectTo(persistentSiteUrl); + if (options.updateUrl !== false) { + redirectTo(persistentSiteUrl); + } if (!options.skipRenameModal) { dispatch(setActiveModal('rename-site')); } diff --git a/packages/playground/website/src/lib/state/redux/site-management-api-middleware.ts b/packages/playground/website/src/lib/state/redux/site-management-api-middleware.ts index 3e9413897e5..151375fa0ac 100644 --- a/packages/playground/website/src/lib/state/redux/site-management-api-middleware.ts +++ b/packages/playground/website/src/lib/state/redux/site-management-api-middleware.ts @@ -167,6 +167,21 @@ export interface PlaygroundSitesAPI { excludeFromPruning?: string[]; } ): Promise; + + /** + * Autosaves an active temporary site without changing the URL. + * + * @param siteSlug Optional slug. Uses the active site when omitted. + * @param options Optional autosave behavior overrides. + * @returns The site's slug and storage type. + */ + autosaveTemporarySite( + siteSlug?: string, + options?: { + updateUrl?: boolean; + excludeFromPruning?: string[]; + } + ): Promise<{ slug: string; storage: string }>; } export const siteManagementMiddleware = createListenerMiddleware(); @@ -255,6 +270,37 @@ export function createSitesAPI( return { slug: site.slug, storage }; }, + async autosaveTemporarySite(siteSlug?: string, options = {}) { + const site = siteSlug + ? selectSiteBySlug(getState(), siteSlug) + : selectActiveSite(getState()); + if (!site) { + throw new Error('No site selected'); + } + if (site.metadata.storage !== 'none') { + return { slug: site.slug, storage: site.metadata.storage }; + } + await dispatch( + persistTemporarySite(site.slug, 'opfs', { + skipRenameModal: true, + persistence: 'autosave', + updateUrl: options.updateUrl ?? false, + keepOriginalUrlParams: true, + }) + ); + await dispatch( + pruneAutosavedSites({ + excludeSlugs: [ + site.slug, + ...(options.excludeFromPruning ?? []), + ], + }) + ); + const updatedSite = selectSiteBySlug(getState(), site.slug); + const storage = updatedSite?.metadata.storage ?? 'none'; + return { slug: site.slug, storage }; + }, + async keep(siteSlug?: string) { const site = siteSlug ? selectSiteBySlug(getState(), siteSlug) From eef16cc8c64fa353dfb0144c7d5fe05e5ca90eb9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Thu, 21 May 2026 17:33:06 +0200 Subject: [PATCH 30/47] Trim saved playground UI extras --- .../website/playwright/e2e/website-ui.spec.ts | 3 +- .../src/components/browser-chrome/index.tsx | 3 - .../browser-chrome/style.module.css | 11 +- plan.md | 144 ------------------ 4 files changed, 3 insertions(+), 158 deletions(-) delete mode 100644 plan.md diff --git a/packages/playground/website/playwright/e2e/website-ui.spec.ts b/packages/playground/website/playwright/e2e/website-ui.spec.ts index 457f6508e48..03e7b1c81e0 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 @@ -20,7 +21,7 @@ function getUniqueSavedPlaygroundSetupUrl( return `./?${searchParams}`; } -async function runPHPAndFlushOpfs(page: any, code: string) { +async function runPHPAndFlushOpfs(page: Page, code: string) { await expect .poll( () => diff --git a/packages/playground/website/src/components/browser-chrome/index.tsx b/packages/playground/website/src/components/browser-chrome/index.tsx index 270b7930aaa..4e9dccc8054 100644 --- a/packages/playground/website/src/components/browser-chrome/index.tsx +++ b/packages/playground/website/src/components/browser-chrome/index.tsx @@ -104,9 +104,6 @@ export default function BrowserChrome({ className={css.savedPlaygroundsButton} > - - Your Playgrounds -
From e640a5259ae8dc8b7a60d961d2f28e550b4af3a3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Thu, 21 May 2026 19:13:29 +0200 Subject: [PATCH 35/47] Polish autosave status popover --- .../website/playwright/e2e/website-ui.spec.ts | 17 +++++--- .../save-status-indicator.module.css | 7 ++-- .../browser-chrome/save-status-indicator.tsx | 39 +++++++++++++++---- .../ensure-playground-site-is-selected.tsx | 8 +--- .../src/lib/state/redux/boot-site-client.ts | 11 ++---- 5 files changed, 52 insertions(+), 30 deletions(-) diff --git a/packages/playground/website/playwright/e2e/website-ui.spec.ts b/packages/playground/website/playwright/e2e/website-ui.spec.ts index 268eb7c20e9..6e19160677a 100644 --- a/packages/playground/website/playwright/e2e/website-ui.spec.ts +++ b/packages/playground/website/playwright/e2e/website-ui.spec.ts @@ -826,7 +826,9 @@ test.describe('Default Playground storage', () => { website.page.getByText('Recent autosave available') ).toBeVisible(); await expect( - website.page.getByText('from the same setup URL.') + website.page.getByText( + /Another Playground was created .* from the same setup URL\./ + ) ).toBeVisible(); await website.waitForNestedIframes(); await expect( @@ -967,11 +969,14 @@ echo get_option('blogname'); await expect(indicator).toBeVisible(); await expect(indicator).toHaveCount(1); await indicator.click(); - await expect( - website.page.getByText( - 'This Playground is not stored anywhere. Changes are lost when this page is refreshed or closed.' - ) - ).toBeVisible(); + const popoverDescription = website.page.getByText( + 'This Playground is not stored anywhere. Changes are lost when this page is refreshed or closed.' + ); + await expect(popoverDescription).toBeVisible(); + await indicator.click(); + await expect(popoverDescription).toHaveCount(0); + await indicator.click(); + await expect(popoverDescription).toBeVisible(); await website.page .getByRole('button', { name: 'Store permanently' }) .click(); diff --git a/packages/playground/website/src/components/browser-chrome/save-status-indicator.module.css b/packages/playground/website/src/components/browser-chrome/save-status-indicator.module.css index 6aa334d36bd..1ae7873b241 100644 --- a/packages/playground/website/src/components/browser-chrome/save-status-indicator.module.css +++ b/packages/playground/website/src/components/browser-chrome/save-status-indicator.module.css @@ -109,8 +109,8 @@ .primary-action { align-items: center; - background: #1e1e1e; - border: none; + background: #3858e9; + border: 1px solid #6c84ff; border-radius: 4px; color: #fff; cursor: pointer; @@ -127,7 +127,8 @@ .primary-action:hover, .primary-action:focus-visible { - background: #2f2f2f; + background: #4f6df0; + border-color: #8fa1ff; } .spinner { diff --git a/packages/playground/website/src/components/browser-chrome/save-status-indicator.tsx b/packages/playground/website/src/components/browser-chrome/save-status-indicator.tsx index c432f955179..ad4dafed741 100644 --- a/packages/playground/website/src/components/browser-chrome/save-status-indicator.tsx +++ b/packages/playground/website/src/components/browser-chrome/save-status-indicator.tsx @@ -46,18 +46,19 @@ function getSyncLabel({ isAutosaved: boolean; progress: Extract['progress']; }) { + if (isAutosaved) { + return 'Unsaved'; + } if ( progress?.phase === 'flushing' || (progress && progress.total > 0 && progress.files >= progress.total) ) { - return isAutosaved ? 'Finalizing autosave...' : 'Finalizing save...'; + return 'Finalizing save...'; } if (progress) { - return isAutosaved - ? `Autosaving ${progress.files}/${progress.total}...` - : `Saving ${progress.files}/${progress.total}...`; + return `Saving ${progress.files}/${progress.total}...`; } - return isAutosaved ? 'Autosaving...' : 'Saving...'; + return 'Saving...'; } export function SaveStatusIndicator() { @@ -65,6 +66,7 @@ export function SaveStatusIndicator() { const activeSite = useActiveSite(); const dispatch = useAppDispatch(); const statusButtonRef = useRef(null); + const suppressNextTriggerClickRef = useRef(false); const [isPopoverOpen, setIsPopoverOpen] = useState(false); const opfsSync = clientInfo?.opfsSync; @@ -83,6 +85,27 @@ export function SaveStatusIndicator() { } }; + const handleTriggerMouseDown = ( + event: React.MouseEvent + ) => { + if (!isPopoverOpen) { + return; + } + event.preventDefault(); + event.stopPropagation(); + suppressNextTriggerClickRef.current = true; + setIsPopoverOpen(false); + }; + + const handleTriggerClick = (event: React.MouseEvent) => { + event.stopPropagation(); + if (suppressNextTriggerClickRef.current) { + suppressNextTriggerClickRef.current = false; + return; + } + setIsPopoverOpen((isOpen) => !isOpen); + }; + if (status === 'saved') { return (
@@ -102,7 +125,8 @@ export function SaveStatusIndicator() { css.autosaved, css.actionable )} - onClick={() => setIsPopoverOpen((isOpen) => !isOpen)} + onMouseDown={handleTriggerMouseDown} + onClick={handleTriggerClick} aria-expanded={isPopoverOpen} type="button" > @@ -177,7 +201,8 @@ export function SaveStatusIndicator() { css.unsaved, css.actionable )} - onClick={() => setIsPopoverOpen((isOpen) => !isOpen)} + onMouseDown={handleTriggerMouseDown} + onClick={handleTriggerClick} aria-expanded={isPopoverOpen} type="button" > diff --git a/packages/playground/website/src/components/ensure-playground-site/ensure-playground-site-is-selected.tsx b/packages/playground/website/src/components/ensure-playground-site/ensure-playground-site-is-selected.tsx index 4ed446511e7..fbc83e81e0e 100644 --- a/packages/playground/website/src/components/ensure-playground-site/ensure-playground-site-is-selected.tsx +++ b/packages/playground/website/src/components/ensure-playground-site/ensure-playground-site-is-selected.tsx @@ -266,18 +266,14 @@ function RestoreAutosaveNudge({ onRestore: () => void; onKeepNew: () => void; }) { - const lastUsed = new Date( - (site.metadata.whenLastUsed ?? - site.metadata.whenCreated ?? - Date.now()) - 2 - ); + const createdAt = new Date((site.metadata.whenCreated ?? Date.now()) - 2); return (