diff --git a/packages/playground/website/playwright/e2e/opfs.spec.ts b/packages/playground/website/playwright/e2e/opfs.spec.ts index 7b07e541c0..91e9e45c9c 100644 --- a/packages/playground/website/playwright/e2e/opfs.spec.ts +++ b/packages/playground/website/playwright/e2e/opfs.spec.ts @@ -642,6 +642,46 @@ test.describe('OPFS', { tag: '@storage' }, () => { await website.waitForNestedIframes(); }); + test('should not offer an unfinished initial OPFS sync loaded from storage as a recent autosave', async ({ + website, + browserName, + }) => { + test.skip( + browserName !== 'chromium', + `This test relies on OPFS which isn't available in Playwright's flavor of ${browserName}.` + ); + + const interruptedSiteSlug = `interrupted-autosave-${Date.now()}`; + await website.page.goto(getTemporaryPlaygroundUrl()); + await website.page.waitForFunction( + () => !!navigator.storage?.getDirectory + ); + await writeInterruptedInitialOpfsSite( + website.page, + interruptedSiteSlug + ); + + await website.page.goto('./'); + const restoreNudge = website.page.getByLabel( + 'Recent autosaved Playground' + ); + const autosavedStatus = website.page.getByRole('button', { + name: 'Autosaved', + }); + await expect(restoreNudge.or(autosavedStatus)).toBeVisible({ + timeout: 120000, + }); + + expect(await restoreNudge.count()).toBe(0); + await expect(autosavedStatus).toBeVisible(); + await expect + .poll(() => getActivePlaygroundSite(website.page)) + .toMatchObject({ + storage: 'opfs', + persistence: 'autosave', + }); + }); + test('should switch between sites', async ({ website, browserName }) => { test.skip( browserName !== 'chromium', diff --git a/packages/playground/website/playwright/e2e/website-ui.spec.ts b/packages/playground/website/playwright/e2e/website-ui.spec.ts index 4a2f03d67e..7956671303 100644 --- a/packages/playground/website/playwright/e2e/website-ui.spec.ts +++ b/packages/playground/website/playwright/e2e/website-ui.spec.ts @@ -1,6 +1,7 @@ import { test, expect } from '../playground-fixtures.ts'; import type { Blueprint } from '@wp-playground/blueprints'; import type { Page } from '@playwright/test'; +import { getDirectoryNameForSlug } from '../../src/lib/state/opfs/opfs-site-path'; // We can't import the SupportedPHPVersions versions directly from the remote package // because of ESModules vs CommonJS incompatibilities. Let's just import the @@ -3434,6 +3435,24 @@ echo get_option('blogname'); activeSite?.persistence === 'autosave' ); }); + const initialAutosave = await getActivePlaygroundSite(website.page); + const initialAutosaveDirectory = getDirectoryNameForSlug( + initialAutosave.slug + ); + await expect + .poll(() => + website.page.evaluate(async (siteDirectoryName) => { + const root = await navigator.storage.getDirectory(); + const sites = await root.getDirectoryHandle('sites'); + const site = + await sites.getDirectoryHandle(siteDirectoryName); + const metadata = + await site.getFileHandle('wp-runtime.json'); + return JSON.parse(await (await metadata.getFile()).text()) + .initialOpfsSyncPending; + }, initialAutosaveDirectory) + ) + .toBe(false); await website.goto(setupUrl); await expect( 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 f5da309927..f08ec3396c 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 @@ -24,6 +24,7 @@ import { } from './slice-ui'; import type { PlaygroundDispatch, PlaygroundReduxState } from './store'; import { + hasUnfinishedInitialOpfsSyncFromStorage, isAutosavedSite, isUnfinishedBlueprintRun, selectSiteBySlug, @@ -96,12 +97,9 @@ export function bootSiteClient( return; } site = selectSiteBySlug(getState(), siteSlug) ?? site; - } else if ( - site.loadedFromStorage === true && - site.metadata.initialOpfsSyncPending === true - ) { - // If the initial OPFS sync was interrupted, the site files are incomplete - // and we can't boot this site. + } else if (hasUnfinishedInitialOpfsSyncFromStorage(site)) { + // The initial OPFS copy has not finished in storage, so the site files + // are incomplete and we cannot boot this site yet. dispatch( setActiveSiteError({ error: 'initial-opfs-sync-interrupted', 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 d21787ee54..09282f2edd 100644 --- a/packages/playground/website/src/lib/state/redux/site-lifecycle.ts +++ b/packages/playground/website/src/lib/state/redux/site-lifecycle.ts @@ -82,6 +82,22 @@ export function isTemporarySite(site: SiteInfo) { return !isStoredSite(site); } +/** + * Indicates whether a site was loaded before its first OPFS copy completed. + * + * New stored Playgrounds keep this flag while their live client copies its + * initialized files. If another document loads the record before that flag is + * cleared, the files are not yet bootable. The creating document may still be + * syncing or may have stopped. + */ +export function hasUnfinishedInitialOpfsSyncFromStorage(site: SiteInfo) { + return ( + site.loadedFromStorage === true && + isOpfsBackedSite(site) && + site.metadata.initialOpfsSyncPending === true + ); +} + /** * Indicates whether a site has durable storage. Autosaved sites are stored but * not explicitly saved, so callers that need user-pinned sites use 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 2227ba12cc..34213eff69 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 @@ -111,16 +111,27 @@ describe('stored sites', () => { vi.doUnmock('./store'); }); - it('classifies a normal autosave but not an unfinished Blueprint run as restorable', async () => { + it('only classifies bootable autosaves as restorable', async () => { const { isRestorableAutosavedSite } = await import('./slice-sites'); const autosave = createSiteInfo({ slug: 'autosave' }); autosave.metadata.persistence = 'autosave'; + const inProgressAutosave = createSiteInfo({ slug: 'in-progress' }); + inProgressAutosave.metadata.persistence = 'autosave'; + inProgressAutosave.metadata.initialOpfsSyncPending = true; + const storedUnfinishedAutosave = createSiteInfo({ + slug: 'stored-unfinished', + }); + storedUnfinishedAutosave.loadedFromStorage = true; + storedUnfinishedAutosave.metadata.persistence = 'autosave'; + storedUnfinishedAutosave.metadata.initialOpfsSyncPending = true; const unfinishedRun = createSiteInfo({ slug: 'unfinished-run' }); unfinishedRun.metadata.persistence = 'autosave'; unfinishedRun.metadata.siteSlugToReturnToIfBlueprintFails = 'source-site'; expect(isRestorableAutosavedSite(autosave)).toBe(true); + expect(isRestorableAutosavedSite(inProgressAutosave)).toBe(true); + expect(isRestorableAutosavedSite(storedUnfinishedAutosave)).toBe(false); expect(isRestorableAutosavedSite(unfinishedRun)).toBe(false); }); 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 b2df9c20cc..bc6d5092db 100644 --- a/packages/playground/website/src/lib/state/redux/slice-sites.ts +++ b/packages/playground/website/src/lib/state/redux/slice-sites.ts @@ -35,6 +35,7 @@ import { deriveSlugFromSiteName, getUniqueSiteSlug } from './site-slug'; import { getAutosavedSitesToPrune, getSitesSortedByRecency, + hasUnfinishedInitialOpfsSyncFromStorage, isAutosavedSite, isStoredSite, isTemporarySite, @@ -53,6 +54,7 @@ export { getSiteRecencyTimestamp, getSitesSortedByRecency, getSitePublicPersistence, + hasUnfinishedInitialOpfsSyncFromStorage, isAutosavedSite, isExplicitlySavedSite, isOpfsBackedSite, @@ -408,15 +410,19 @@ export function pruneAutosavedSites( /** * Checks whether a stored Playground may be offered as an autosave. * - * A Blueprint run uses autosave persistence before its initial OPFS copy - * succeeds. Its return target marks it unfinished, so callers withhold it from - * Recent and matching-setup restore suggestions until that copy succeeds. + * An unfinished first OPFS copy loaded from storage cannot boot yet. A + * Blueprint run also uses autosave persistence before that copy succeeds. + * Callers withhold both from Recent and matching-setup restore suggestions. * * @param site The Playground whose autosave lifecycle should be checked. * @returns Whether the Playground is a restorable autosave. */ export function isRestorableAutosavedSite(site: SiteInfo) { - return isAutosavedSite(site) && !isUnfinishedBlueprintRun(site); + return ( + isAutosavedSite(site) && + !hasUnfinishedInitialOpfsSyncFromStorage(site) && + !isUnfinishedBlueprintRun(site) + ); } /**