Skip to content

Commit 7cc0450

Browse files
committed
Add saved site creation APIs
1 parent 3b76840 commit 7cc0450

7 files changed

Lines changed: 637 additions & 61 deletions

File tree

packages/playground/website/playwright/e2e/sites-api.spec.ts

Lines changed: 86 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,9 @@ test('playgroundSites.list() returns the active site', async ({ website }) => {
2020
const active = sites.find((s: any) => s.isActive);
2121
expect(active).toBeTruthy();
2222
expect(active.slug).toBeTruthy();
23-
expect(active.storage).toBe('temporary');
23+
// Storage-specific behavior is covered below. This test only checks that
24+
// the list exposes the active Playground through the public API shape.
25+
expect(['opfs', 'local-fs', 'temporary']).toContain(active.storage);
2426
});
2527

2628
test('playgroundSites.saveInBrowser() persists a temporary site', async ({
@@ -32,7 +34,7 @@ test('playgroundSites.saveInBrowser() persists a temporary site', async ({
3234
`This test relies on OPFS which isn't available in Playwright's flavor of ${browserName}.`
3335
);
3436

35-
await website.goto('./');
37+
await website.goto('./?storage=temp');
3638
await website.page.waitForFunction(() =>
3739
Boolean((window as any).playgroundSites?.getClient())
3840
);
@@ -44,6 +46,87 @@ test('playgroundSites.saveInBrowser() persists a temporary site', async ({
4446
expect(result.storage).toBe('opfs');
4547
});
4648

49+
test('playgroundSites.autosaveTemporarySite() persists without disrupting the tab', async ({
50+
website,
51+
browserName,
52+
}) => {
53+
test.skip(
54+
browserName !== 'chromium',
55+
`This test relies on OPFS which isn't available in Playwright's flavor of ${browserName}.`
56+
);
57+
58+
await website.goto('./?storage=temp');
59+
await website.page.waitForFunction(() =>
60+
Boolean((window as any).playgroundSites?.getClient())
61+
);
62+
63+
const result = await website.page.evaluate(async () => {
64+
const api = (window as any).playgroundSites;
65+
const beforeUrl = window.location.href;
66+
const beforeClient = api.getClient();
67+
const beforeActive = api.list().find((s: any) => s.isActive);
68+
const saveResult = await api.autosaveTemporarySite();
69+
const afterActive = api.list().find((s: any) => s.isActive);
70+
return {
71+
beforeSlug: beforeActive?.slug,
72+
saveResult,
73+
afterActive,
74+
sameClient: beforeClient === api.getClient(),
75+
sameUrl: window.location.href === beforeUrl,
76+
};
77+
});
78+
79+
// Autosave should promote the active temporary site to browser storage
80+
// without changing the visible URL or replacing the running Playground.
81+
expect(result.saveResult.slug).toBe(result.beforeSlug);
82+
expect(result.saveResult.storage).toBe('opfs');
83+
expect(result.afterActive.storage).toBe('opfs');
84+
expect(result.afterActive.persistence).toBe('autosave');
85+
expect(result.sameClient).toBe(true);
86+
expect(result.sameUrl).toBe(true);
87+
});
88+
89+
test('playgroundSites.createNewSavedSite() creates an explicit OPFS site by default', async ({
90+
website,
91+
browserName,
92+
}) => {
93+
test.skip(
94+
browserName !== 'chromium',
95+
`This test relies on OPFS which isn't available in Playwright's flavor of ${browserName}.`
96+
);
97+
98+
await website.goto('./?storage=temp');
99+
await website.page.waitForFunction(() =>
100+
Boolean((window as any).playgroundSites?.getClient())
101+
);
102+
103+
const result = await website.page.evaluate(async () => {
104+
const api = (window as any).playgroundSites;
105+
const beforeUrl = window.location.href;
106+
const slug = await api.createNewSavedSite(
107+
'api-created-site',
108+
{ phpVersion: '8.3' },
109+
{ updateUrl: false }
110+
);
111+
const active = api.list().find((s: any) => s.isActive);
112+
return {
113+
slug,
114+
active,
115+
sameUrl: window.location.href === beforeUrl,
116+
hasClient: api.getClient() != null,
117+
};
118+
});
119+
120+
// The new API creates a saved site record, boots it, and marks it as an
121+
// explicit save unless the caller opts into autosave persistence.
122+
expect(result.slug).toBe('api-created-site');
123+
expect(result.active.slug).toBe('api-created-site');
124+
expect(result.active.storage).toBe('opfs');
125+
expect(result.active.persistence).toBe('explicit');
126+
expect(result.hasClient).toBe(true);
127+
expect(result.sameUrl).toBe(true);
128+
});
129+
47130
test('playgroundSites.rename() renames a saved site', async ({
48131
website,
49132
browserName,
@@ -60,8 +143,8 @@ test('playgroundSites.rename() renames a saved site', async ({
60143

61144
const newName = await website.page.evaluate(async () => {
62145
const api = (window as any).playgroundSites;
63-
await api.saveInBrowser();
64146
const name = 'Renamed Via API';
147+
await api.saveInBrowser();
65148
await api.rename(name);
66149
const sites = api.list();
67150
const active = sites.find((s: any) => s.isActive);

packages/playground/website/src/lib/state/redux/boot-site-client.ts

Lines changed: 155 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ import {
1717
isBlueprintBundle,
1818
} from '@wp-playground/blueprints';
1919
import { logger } from '@php-wasm/logger';
20-
import { setupPostMessageRelay } from '@php-wasm/web';
20+
import { type SyncProgress, setupPostMessageRelay } from '@php-wasm/web';
2121
import { startPlaygroundWeb } from '@wp-playground/client';
2222
import type { PlaygroundClient } from '@wp-playground/remote';
2323
import { getRemoteUrl } from '../../config';
@@ -27,7 +27,11 @@ import {
2727
setGitHubAuthRepoUrl,
2828
} from './slice-ui';
2929
import type { PlaygroundDispatch, PlaygroundReduxState } from './store';
30-
import { selectSiteBySlug } from './slice-sites';
30+
import {
31+
isAutosavedSite,
32+
selectSiteBySlug,
33+
updateSiteMetadata,
34+
} from './slice-sites';
3135
// @ts-ignore
3236
import { corsProxyUrl } from 'virtual:cors-proxy-url';
3337
import { modalSlugs } from './slice-ui';
@@ -142,7 +146,10 @@ export function bootSiteClient(
142146
},
143147
extraLibraries: site.metadata.runtimeConfiguration
144148
.extraLibraries as any[],
145-
constants: site.metadata.runtimeConfiguration.constants,
149+
constants: {
150+
...site.metadata.runtimeConfiguration.constants,
151+
...site.metadata.playgroundDefinedConstants,
152+
},
146153
};
147154
} else {
148155
blueprint = site.metadata.originalBlueprint;
@@ -161,6 +168,27 @@ export function bootSiteClient(
161168
? 'install-from-existing-files-if-needed'
162169
: 'download-and-install';
163170

171+
// A newly created saved site has wp-runtime.json in OPFS, but not the
172+
// WordPress tree yet. The first boot must let the setup URL build that
173+
// tree in MEMFS. Passing the OPFS mount here would copy the
174+
// metadata-only directory into /wordpress, so seed OPFS from MEMFS after
175+
// the iframe connects instead.
176+
const shouldFinishInitialOpfsSyncInBackground =
177+
site.metadata.initialOpfsSyncPending === true &&
178+
site.metadata.storage === 'opfs' &&
179+
!!mountDescriptor &&
180+
!isWordPressInstalled;
181+
const syncOperation = isAutosavedSite(site) ? 'autosave' : 'save';
182+
const mounts =
183+
mountDescriptor && !shouldFinishInitialOpfsSyncInBackground
184+
? [
185+
{
186+
...mountDescriptor,
187+
initialSyncDirection: 'opfs-to-memfs' as const,
188+
},
189+
]
190+
: [];
191+
164192
let playground: PlaygroundClient | undefined = undefined;
165193
try {
166194
const phpExtensions = phpExtensionQueryArgsToExtensionsArray(
@@ -187,14 +215,7 @@ export function bootSiteClient(
187215
},
188216
// Log Blueprint events
189217
onBlueprintValidated: logBlueprintEvents,
190-
mounts: mountDescriptor
191-
? [
192-
{
193-
...mountDescriptor,
194-
initialSyncDirection: 'opfs-to-memfs',
195-
},
196-
]
197-
: [],
218+
mounts,
198219
wordpressInstallMode,
199220
corsProxy: corsProxyUrl,
200221
gitAdditionalHeadersCallback: createGitAuthHeaders(),
@@ -290,19 +311,140 @@ export function bootSiteClient(
290311
if (signal.aborted || !playground) {
291312
return;
292313
}
314+
const connectedPlayground = playground as PlaygroundClient;
293315

294316
setupPostMessageRelay(iframe, document.location.origin);
295317

296318
dispatch(
297319
addClientInfo({
298320
siteSlug: site.slug,
299321
url: '/',
300-
client: playground,
322+
client: connectedPlayground,
301323
opfsMountDescriptor: mountDescriptor,
324+
opfsSync: shouldFinishInitialOpfsSyncInBackground
325+
? {
326+
status: 'syncing',
327+
operation: syncOperation,
328+
}
329+
: undefined,
302330
})
303331
);
332+
if (site.metadata.storage !== 'none') {
333+
try {
334+
await dispatch(
335+
updateSiteMetadata({
336+
slug: site.slug,
337+
changes: {
338+
whenLastUsed: Date.now(),
339+
},
340+
})
341+
);
342+
} catch (error) {
343+
logger.error('Error updating Playground last-used time', error);
344+
}
345+
}
346+
347+
// If WordPress was already present in OPFS, the regular OPFS-to-MEMFS
348+
// mount above completed the initial sync path.
349+
const shouldClearInitialOpfsSyncPending =
350+
site.metadata.initialOpfsSyncPending === true &&
351+
site.metadata.storage === 'opfs' &&
352+
!!mountDescriptor &&
353+
!shouldFinishInitialOpfsSyncInBackground;
354+
if (shouldClearInitialOpfsSyncPending) {
355+
try {
356+
await dispatch(
357+
updateSiteMetadata({
358+
slug: site.slug,
359+
changes: {
360+
initialOpfsSyncPending: false,
361+
},
362+
})
363+
);
364+
} catch (error) {
365+
logger.error(
366+
'Error updating Playground initial sync state',
367+
error
368+
);
369+
}
370+
}
371+
372+
if (shouldFinishInitialOpfsSyncInBackground && mountDescriptor) {
373+
// Progress callbacks cross worker and iframe boundaries asynchronously.
374+
// A final progress message may arrive after mountOpfs() resolves and
375+
// must not resurrect the completed sync state in Redux.
376+
let opfsMountSettled = false;
377+
void connectedPlayground
378+
.mountOpfs(
379+
{
380+
...mountDescriptor,
381+
initialSyncDirection: 'memfs-to-opfs',
382+
},
383+
(progress: SyncProgress) => {
384+
if (opfsMountSettled) {
385+
return;
386+
}
387+
dispatch(
388+
updateClientInfo({
389+
siteSlug: site.slug,
390+
changes: {
391+
opfsSync: {
392+
status: 'syncing',
393+
progress,
394+
operation: syncOperation,
395+
},
396+
},
397+
})
398+
);
399+
}
400+
)
401+
.then(async () => {
402+
opfsMountSettled = true;
403+
try {
404+
await dispatch(
405+
updateSiteMetadata({
406+
slug: site.slug,
407+
changes: {
408+
initialOpfsSyncPending: false,
409+
},
410+
})
411+
);
412+
} catch (error) {
413+
logger.error(
414+
'Error updating Playground initial sync state',
415+
error
416+
);
417+
}
418+
dispatch(
419+
updateClientInfo({
420+
siteSlug: site.slug,
421+
changes: {
422+
opfsSync: undefined,
423+
},
424+
})
425+
);
426+
})
427+
.catch((error: unknown) => {
428+
opfsMountSettled = true;
429+
logger.error(
430+
'Error syncing saved Playground to OPFS',
431+
error
432+
);
433+
dispatch(
434+
updateClientInfo({
435+
siteSlug: site.slug,
436+
changes: {
437+
opfsSync: {
438+
status: 'error',
439+
operation: syncOperation,
440+
},
441+
},
442+
})
443+
);
444+
});
445+
}
304446

305-
(playground as PlaygroundClient).onNavigation((url) => {
447+
connectedPlayground.onNavigation((url) => {
306448
dispatch(
307449
updateClientInfo({
308450
siteSlug: site.slug,

0 commit comments

Comments
 (0)