Skip to content
150 changes: 143 additions & 7 deletions packages/playground/website/src/lib/state/redux/slice-sites.spec.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import type { OriginalUrlParams } from '../original-url-params';
import type { SiteInfo } from './slice-sites';
import type { TraversableFilesystemBackend } from '@wp-playground/storage';

describe('stored site specs', () => {
describe('stored site creation', () => {
let createSite: ReturnType<typeof vi.fn>;
let updateSiteStorage: ReturnType<typeof vi.fn>;
let persistBlueprintBundle: ReturnType<typeof vi.fn>;
let deleteBlueprintBundle: ReturnType<typeof vi.fn>;
let removeWordPressFilesKeepMetadata: ReturnType<typeof vi.fn>;
let resolveRuntimeConfiguration: ReturnType<typeof vi.fn>;

Expand All @@ -13,6 +15,7 @@ describe('stored site specs', () => {
createSite = vi.fn();
updateSiteStorage = vi.fn();
persistBlueprintBundle = vi.fn();
deleteBlueprintBundle = vi.fn();
removeWordPressFilesKeepMetadata = vi.fn();
resolveRuntimeConfiguration = vi.fn();

Expand All @@ -35,6 +38,7 @@ describe('stored site specs', () => {
resolveRuntimeConfiguration,
}));
vi.doMock('../opfs/opfs-blueprint-bundle-storage', () => ({
deleteBlueprintBundle,
isTraversableFilesystemBackend: (value: unknown) =>
typeof value === 'object' &&
value !== null &&
Expand Down Expand Up @@ -315,11 +319,13 @@ describe('stored site specs', () => {
expect(removeWordPressFilesKeepMetadata).not.toHaveBeenCalled();
});

it('does not persist a bundle for a new stored site before validating setup', async () => {
it('keeps setStoredSiteSpec as the setup URL compatibility alias', async () => {
resolveRuntimeConfiguration.mockRejectedValue(
new Error('Invalid setup')
);
const { setStoredSiteSpec } = await import('./slice-sites');
const { createStoredSite, setStoredSiteSpec } =
await import('./slice-sites');
expect(setStoredSiteSpec).toBe(createStoredSite);
const addSite = setStoredSiteSpec(
'Autosaved site',
new URL(
Expand All @@ -335,6 +341,121 @@ describe('stored site specs', () => {

expect(persistBlueprintBundle).not.toHaveBeenCalled();
});

it('creates a new stored site from an edited Blueprint bundle', async () => {
const { createStoredSite, sitesSlice } = await import('./slice-sites');
const runtimeConfiguration = {
phpVersion: '8.3',
wpVersion: 'latest',
intl: false,
networking: true,
extraLibraries: [],
constants: {},
};
resolveRuntimeConfiguration.mockResolvedValue(runtimeConfiguration);
const sourceSite = createSiteInfo({
slug: 'source-site',
name: 'Original Playground',
});
const editedBundle = createBundleBlueprint();
let state = {
sites: sitesSlice.reducer(
undefined,
sitesSlice.actions.addSite(sourceSite)
),
};
const getState = () => state;
const dispatch = vi.fn();
dispatch.mockImplementation((action) => {
if (typeof action === 'function') {
return action(dispatch, getState);
}
state = {
sites: sitesSlice.reducer(state.sites, action),
};
return action;
});
const writes: string[] = [];
persistBlueprintBundle.mockImplementation(async () => {
writes.push('bundle');
});
createSite.mockImplementation(async () => {
writes.push('metadata');
});

const newSite = await createStoredSite(
'Edited Blueprint',
editedBundle,
'source-site',
{ persistence: 'autosave' }
)(dispatch as any, getState as any);

expect(newSite.slug).toBe('source-site-2');
expect(newSite.metadata).toMatchObject({
name: 'Edited Blueprint',
storage: 'opfs',
persistence: 'autosave',
initialOpfsSyncPending: true,
originalBlueprint: editedBundle,
originalBlueprintSource: { type: 'opfs-site' },
runtimeConfiguration,
});
expect(persistBlueprintBundle).toHaveBeenCalledWith(
'source-site-2',
editedBundle
);
expect(createSite).toHaveBeenCalledWith(
'source-site-2',
newSite.metadata,
undefined
);
expect(deleteBlueprintBundle).not.toHaveBeenCalled();
expect(writes).toEqual(['bundle', 'metadata']);
expect(state.sites.entities).toMatchObject({
'source-site': sourceSite,
'source-site-2': newSite,
});
});

it('does not persist an edited Blueprint bundle when its runtime is invalid', async () => {
resolveRuntimeConfiguration.mockRejectedValue(
new Error('Invalid setup')
);
const { createStoredSite } = await import('./slice-sites');

await expect(
createStoredSite('Edited Blueprint', createBundleBlueprint())(
createDispatch() as any,
createEmptyGetState() as any
)
).rejects.toThrow('Invalid setup');

expect(persistBlueprintBundle).not.toHaveBeenCalled();
expect(createSite).not.toHaveBeenCalled();
});

it('removes an edited Blueprint bundle when site creation fails', async () => {
const { createStoredSite } = await import('./slice-sites');
resolveRuntimeConfiguration.mockResolvedValue({
phpVersion: '8.3',
wpVersion: 'latest',
intl: false,
networking: true,
extraLibraries: [],
constants: {},
});
createSite.mockRejectedValue(new Error('Could not write metadata'));

await expect(
createStoredSite(
'Edited Blueprint',
createBundleBlueprint(),
'edited-blueprint'
)(createThunkDispatch() as any, createEmptyGetState() as any)
).rejects.toThrow('Could not write metadata');

expect(deleteBlueprintBundle).toHaveBeenCalledWith('edited-blueprint');
});
});

function createEmptyGetState() {
Expand Down Expand Up @@ -381,15 +502,19 @@ function createGetState() {

function createSiteInfo({
originalUrlParams,
slug = 'stored-site',
name = 'Stored site',
}: {
originalUrlParams?: OriginalUrlParams;
slug?: string;
name?: string;
} = {}): SiteInfo {
return {
slug: 'stored-site',
slug,
originalUrlParams,
metadata: {
id: 'stored-site',
name: 'Stored site',
id: slug,
name,
storage: 'opfs' as const,
originalBlueprint: {},
originalBlueprintSource: { type: 'none' as const },
Expand All @@ -409,7 +534,18 @@ function createDispatch() {
return vi.fn(async (action) => action);
}

function createBundleBlueprint() {
function createThunkDispatch() {
const dispatch = vi.fn();
dispatch.mockImplementation(async (action) => {
if (typeof action === 'function') {
return action(dispatch, createEmptyGetState());
}
return action;
});
return dispatch;
}

function createBundleBlueprint(): TraversableFilesystemBackend {
return {
read: vi.fn(),
listFiles: vi.fn(),
Expand Down
90 changes: 59 additions & 31 deletions packages/playground/website/src/lib/state/redux/slice-sites.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,11 @@ import {
applyQueryOverrides,
} from '../url/resolve-blueprint-from-url';
import {
deleteBlueprintBundle,
isTraversableFilesystemBackend,
persistBlueprintBundle,
} from '../opfs/opfs-blueprint-bundle-storage';
import type { TraversableFilesystemBackend } from '@wp-playground/storage';
import { logger } from '@php-wasm/logger';
import { setActiveSiteError, type SiteError } from './slice-ui';
import { RecommendedPHPVersion } from '@wp-playground/common';
Expand Down Expand Up @@ -555,18 +557,13 @@ export function setTemporarySiteSpec(
}

/**
* Creates the metadata record for a new OPFS-backed Playground.
*
* This intentionally overlaps with `setTemporarySiteSpec`: both capture the
* setup URL, resolve the Blueprint, and derive the runtime configuration.
* Stored sites diverge after that by writing OPFS metadata immediately, keeping
* more than one site record, and marking the first file sync as pending. Boot
* then uses the same setup URL to install WordPress in MEMFS before copying the
* initialized files into OPFS.
* Creates a new OPFS-backed Playground from a setup URL or editable Blueprint
* bundle. Bundles are stored with the new Playground before their metadata is
* written because they may include edits that cannot be represented by a URL.
*/
export function setStoredSiteSpec(
export function createStoredSite(
siteName: string,
playgroundUrlWithQueryApiArgs: URL,
source: URL | TraversableFilesystemBackend,
preferredSlug?: string,
options: {
/**
Expand All @@ -579,22 +576,39 @@ export function setStoredSiteSpec(
dispatch: PlaygroundDispatch,
getState: () => PlaygroundReduxState
) => {
const originalUrlParams = getOriginalUrlParams(
playgroundUrlWithQueryApiArgs
);
if (!opfsSiteStorage) {
throw new Error(
'Cannot create a saved Playground because browser storage is not available.'
);
}

const resolvedBlueprint = await resolveSiteBlueprintFromUrl(
playgroundUrlWithQueryApiArgs
);
/**
* A setup URL needs resolving and carries its original URL params and
* autosave fingerprint. An editable bundle is already the source of
* truth and will be persisted below, so it has neither.
*/
let blueprint: ResolvedBlueprint['blueprint'];
let originalBlueprintSource: BlueprintSource;
let originalUrlParams: OriginalUrlParams | undefined;
let sourceSetupUrlFingerprint: string | undefined;
if (source instanceof URL) {
const resolvedBlueprint = await resolveSiteBlueprintFromUrl(source);
blueprint = resolvedBlueprint.blueprint;
originalBlueprintSource = resolvedBlueprint.source!;
originalUrlParams = getOriginalUrlParams(source);
sourceSetupUrlFingerprint = getAutosaveFingerprintFromURL(source);
} else {
blueprint = source;
originalBlueprintSource = { type: 'opfs-site' };
}
const runtimeConfiguration =
await resolveRuntimeConfiguration(blueprint);
const now = Date.now();
const sites = selectAllSites(getState());
let displayName = siteName;
let slugBaseName = siteName;
if (!preferredSlug) {
slugBaseName = getDefaultSiteNameFromBlueprint(
resolvedBlueprint.blueprint,
siteName
);
slugBaseName = getDefaultSiteNameFromBlueprint(blueprint, siteName);
displayName = getSiteNameWithCreationTimeIfDuplicate(
slugBaseName,
sites.map((site) => site.metadata.name),
Expand All @@ -605,12 +619,11 @@ export function setStoredSiteSpec(
preferredSlug || deriveSlugFromSiteName(slugBaseName),
{ unavailableSlugs: sites.map((site) => site.slug) }
);
const runtimeConfiguration = (await resolveRuntimeConfiguration(
resolvedBlueprint.blueprint
))!;
let originalBlueprintSource = resolvedBlueprint.source!;
if (isTraversableFilesystemBackend(resolvedBlueprint.blueprint)) {
await persistBlueprintBundle(siteSlug, resolvedBlueprint.blueprint);
const blueprintBundle = isTraversableFilesystemBackend(blueprint)
? blueprint
: undefined;
if (blueprintBundle) {
await persistBlueprintBundle(siteSlug, blueprintBundle);
originalBlueprintSource = {
type: 'opfs-site',
};
Expand All @@ -626,20 +639,35 @@ export function setStoredSiteSpec(
persistence: options.persistence ?? 'explicit',
storage: 'opfs' as const,
initialOpfsSyncPending: true,
sourceSetupUrlFingerprint: getAutosaveFingerprintFromURL(
playgroundUrlWithQueryApiArgs
),
originalBlueprint: resolvedBlueprint.blueprint,
...(sourceSetupUrlFingerprint
? {
sourceSetupUrlFingerprint:
sourceSetupUrlFingerprint,
}
: {}),
originalBlueprint: blueprint,
originalBlueprintSource,
runtimeConfiguration,
},
};

await dispatch(addSite(newSiteInfo));
try {
await dispatch(addSite(newSiteInfo));
} catch (error) {
if (blueprintBundle) {
await deleteBlueprintBundle(siteSlug);
}
throw error;
}
return newSiteInfo;
};
}

/**
* Compatibility alias for callers that create a stored Playground from a URL.
*/
export const setStoredSiteSpec = createStoredSite;

/**
* Replaces an autosaved Playground's WordPress files with files from a new setup.
*
Expand Down
Loading