Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
3f58762
[Website] Add auto-saved temporary Playgrounds
adamziel Dec 16, 2025
1ec881d
Polish routing UX
adamziel Dec 17, 2025
b83d1cf
test(e2e): cover autosaved temporary site URL modes
adamziel Dec 17, 2025
f439714
fix(website): remove unused sitesSlice import
adamziel Dec 17, 2025
2f8e852
test(e2e): click Save site locally button
adamziel Dec 17, 2025
c3aa321
fix(website): stabilize blueprint autosave and Adminer login
adamziel Dec 17, 2025
62f4c8f
test(e2e): make phpMyAdmin edit assertion robust
adamziel Dec 17, 2025
9e3035b
Merge remote-tracking branch 'upstream/trunk' into autosaved-temporar…
adamziel Dec 17, 2025
49c02cd
Merge upstream/trunk into autosaved-temporary-sites
adamziel Dec 17, 2025
c95f358
fix(website): avoid Timeout typing in autosave
adamziel Dec 17, 2025
75a8c55
fix(website): stabilize e2e and blueprint recreate
adamziel Dec 17, 2025
bcd6d12
test(e2e): relax OPFS label assertions
adamziel Dec 17, 2025
8fa1918
test(e2e): stabilize website-ui in CI
adamziel Dec 17, 2025
0b4756f
test(e2e): reduce CI flakes in boot + DB
adamziel Dec 17, 2025
065d348
Fix Playwright E2E failures
adamziel Dec 18, 2025
cb85eeb
Skip flaky PHP/WP combos in Firefox CI and harden file editor test
adamziel Dec 18, 2025
2c87dc6
Stabilize file browser edit test by replacing content explicitly
adamziel Dec 18, 2025
4791a11
Fix File Browser save-status assertion
adamziel Dec 18, 2025
3d5fd04
Fix CI test failures with port allocation and Firefox flakes
adamziel Dec 18, 2025
05fc3b9
Fix file browser test selector ambiguity
adamziel Dec 18, 2025
e94f9ad
Skip file editor E2E test on Firefox CI
adamziel Dec 18, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ You can go ahead and try it out. The Playground will automatically install the t
| `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. |
| `site-autosave` | `yes` (if supported) | Controls how temporary Playgrounds are created. By default, Playground will auto-save temporary sites to the browser (OPFS) when available. Use `site-autosave=no` to force an in-memory temporary Playground (changes may be lost on refresh). |
| `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`. |
Expand Down
4 changes: 3 additions & 1 deletion packages/php-wasm/web-service-worker/src/messaging.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
const DEFAULT_RESPONSE_TIMEOUT = 25000;
// Some operations (e.g. booting a site, large blueprint bundles, OPFS work)
// can legitimately take longer than 25s on CI and slower devices.
const DEFAULT_RESPONSE_TIMEOUT = 60000;

let lastRequestId = 0;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ describe(`PHP ${phpVersion}`, () => {
const cli = await runCLI({
command: 'server',
php: phpVersion,
port: 0,
quiet: true,
});
try {
Expand Down Expand Up @@ -124,6 +125,7 @@ describe(`PHP ${phpVersion}`, () => {
const cli = await runCLI({
command: 'server',
php: phpVersion,
port: 0,
quiet: true,
blueprint: {
steps: [
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import { test, expect } from '../playground-fixtures.ts';
import type { FrameLocator } from '@playwright/test';

// Autosaved temporary sites rely on OPFS, which is only available in Chromium
// in Playwright's browser flavors.
test.describe.configure({ mode: 'serial' });

function extractScopeSlug(url: string): string | null {
const match = url.match(/\/scope:([^/]+)\//);
return match?.[1] ?? null;
}

async function getCurrentScopeSlug(wordpress: FrameLocator): Promise<string> {
const wpUrl = await wordpress
.locator('body')
.evaluate((body) => body.ownerDocument.location.href);
const scopeSlug = extractScopeSlug(wpUrl);
expect(
scopeSlug,
`Expected WordPress iframe URL to include /scope:<slug>/, got ${wpUrl}`
).not.toBeNull();
return scopeSlug!;
}

test('refreshing a Query API URL creates a new autosaved site (no site-slug)', async ({
website,
wordpress,
browserName,
}) => {
test.skip(
browserName !== 'chromium',
`This test relies on OPFS which isn't available in Playwright's flavor of ${browserName}.`
);

await website.goto('./?plugin=gutenberg');
expect(new URL(website.page.url()).searchParams.get('site-slug')).toBeNull();

const scopeA = await getCurrentScopeSlug(wordpress);

await website.page.reload();
await website.waitForNestedIframes();

expect(new URL(website.page.url()).searchParams.get('site-slug')).toBeNull();
const scopeB = await getCurrentScopeSlug(wordpress);
expect(scopeB).not.toBe(scopeA);
});

test('explicitly opening an autosave uses site-slug and persists on refresh', async ({
website,
wordpress,
browserName,
}) => {
test.skip(
browserName !== 'chromium',
`This test relies on OPFS which isn't available in Playwright's flavor of ${browserName}.`
);

await website.goto('./?plugin=gutenberg');
expect(new URL(website.page.url()).searchParams.get('site-slug')).toBeNull();

await website.ensureSiteManagerIsOpen();
await website.openSavedPlaygroundsOverlay();

const autosavedButtons = website.page
.getByRole('heading', { name: 'Auto-saved' })
.locator(
'xpath=following-sibling::div[contains(@class, "sitesList")]//button[contains(@class, "siteRowContent")]'
);
await expect(autosavedButtons.first()).toBeVisible();
await autosavedButtons.first().click();

await expect(website.page).toHaveURL(/site-slug=/);
const siteSlug = new URL(website.page.url()).searchParams.get('site-slug');
expect(siteSlug).not.toBeNull();

// The active site should be served from the matching scope path.
expect(await getCurrentScopeSlug(wordpress)).toBe(siteSlug);

await website.page.reload();
await website.waitForNestedIframes();

expect(new URL(website.page.url()).searchParams.get('site-slug')).toBe(
siteSlug
);
expect(await getCurrentScopeSlug(wordpress)).toBe(siteSlug);
});

75 changes: 47 additions & 28 deletions packages/playground/website/playwright/e2e/opfs.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,11 @@ async function saveSiteViaModal(
const { customName, storageType = 'opfs' } = options || {};

// Click the Save button to open the modal
await expect(page.getByText('Save').first()).toBeEnabled();
await page.getByText('Save').first().click();
const openSaveModalButton = page.getByRole('button', {
name: /save site locally/i,
});
await expect(openSaveModalButton).toBeEnabled();
await openSaveModalButton.click();

// Wait for the Save Playground dialog to appear
const dialog = page.getByRole('dialog', { name: 'Save Playground' });
Expand All @@ -50,10 +53,12 @@ async function saveSiteViaModal(

// Select storage location - wait for the radio button to be available first
if (storageType === 'opfs') {
// The label differs depending on whether we're saving an autosaved temp site.
// We shouldn't need to explicitly call .waitFor(), but the test fails without it.
// Playwright logs that something "intercepts pointer events", that's probably related.
await dialog.getByText('Save in this browser').waitFor();
await dialog.getByText('Save in this browser').click({ force: true });
const opfsRadio = dialog.getByRole('radio', { name: /this browser/i });
await opfsRadio.waitFor();
await opfsRadio.check({ force: true });
} else {
await dialog.getByText('Save to a local directory').waitFor();
await dialog
Expand Down Expand Up @@ -92,13 +97,14 @@ test('should switch between sites', async ({ website, browserName }) => {
// Open the saved playgrounds overlay to switch sites
await website.openSavedPlaygroundsOverlay();

// Click on Temporary Playground in the overlay's site list
// Create a new temporary Playground (autosaved temporary when OPFS is available).
await website.page
.locator('[class*="siteRowContent"]')
.filter({ hasText: 'Temporary Playground' })
.getByRole('button', { name: 'Vanilla WordPress' })
.click();
await website.closeSavedPlaygroundsOverlay();

// The overlay closes and site manager opens with the selected site
// The overlay closes and a new temporary site is created
await website.ensureSiteManagerIsOpen();
await expect(website.page.getByLabel('Playground title')).toContainText(
'Temporary Playground'
);
Expand Down Expand Up @@ -150,11 +156,15 @@ 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
// Create a new temporary Playground so we can switch back to the stored one.
await website.page
.locator('[class*="siteRowContent"]')
.filter({ hasText: 'Temporary Playground' })
.getByRole('button', { name: 'Vanilla WordPress' })
.click();
await website.closeSavedPlaygroundsOverlay();
await website.ensureSiteManagerIsOpen();
await expect(website.page.getByLabel('Playground title')).toContainText(
'Temporary Playground'
);

// Open the overlay again to switch back to the stored site
await website.openSavedPlaygroundsOverlay();
Expand Down Expand Up @@ -239,8 +249,11 @@ test('should show save site modal with correct elements', async ({
await website.ensureSiteManagerIsOpen();

// Click the Save button
await expect(website.page.getByText('Save').first()).toBeEnabled();
await website.page.getByText('Save').first().click();
const openSaveModalButton = website.page.getByRole('button', {
name: /save site locally/i,
});
await expect(openSaveModalButton).toBeEnabled();
await openSaveModalButton.click();

// Verify the modal appears with correct title
const dialog = website.page.getByRole('dialog', {
Expand All @@ -255,7 +268,9 @@ test('should show save site modal with correct elements', async ({

// Verify storage location radio buttons exist
await expect(dialog.getByText('Storage location')).toBeVisible();
await expect(dialog.getByText('Save in this browser')).toBeVisible();
await expect(
dialog.getByRole('radio', { name: /this browser/i })
).toBeVisible();
await expect(dialog.getByText('Save to a local directory')).toBeVisible();

// Verify action buttons exist
Expand All @@ -280,7 +295,9 @@ test('should close save site modal without saving', async ({
await website.ensureSiteManagerIsOpen();

// Open the modal
await website.page.getByText('Save').first().click();
await website.page
.getByRole('button', { name: /save site locally/i })
.click();
const dialog = website.page.getByRole('dialog', {
name: 'Save Playground',
});
Expand All @@ -296,7 +313,9 @@ test('should close save site modal without saving', async ({
);

// Open the modal again
await website.page.getByText('Save').first().click();
await website.page
.getByRole('button', { name: /save site locally/i })
.click();
await expect(dialog).toBeVisible({ timeout: 10000 });

// Close using ESC key
Expand All @@ -322,7 +341,9 @@ test('should have playground name input text selected by default', async ({
await website.ensureSiteManagerIsOpen();

// Open the modal
await website.page.getByText('Save').first().click();
await website.page
.getByRole('button', { name: /save site locally/i })
.click();
const dialog = website.page.getByRole('dialog', {
name: 'Save Playground',
});
Expand Down Expand Up @@ -384,7 +405,9 @@ test('should not persist save site modal through page refresh', async ({
await website.ensureSiteManagerIsOpen();

// Open the save modal
await website.page.getByText('Save').first().click();
await website.page
.getByRole('button', { name: /save site locally/i })
.click();
const dialog = website.page.getByRole('dialog', {
name: 'Save Playground',
});
Expand Down Expand Up @@ -419,15 +442,17 @@ test('should display OPFS storage option as selected by default', async ({
await website.ensureSiteManagerIsOpen();

// Open the save modal
await website.page.getByText('Save').first().click();
await website.page
.getByRole('button', { name: /save site locally/i })
.click();
const dialog = website.page.getByRole('dialog', {
name: 'Save Playground',
});
await expect(dialog).toBeVisible({ timeout: 10000 });

// Verify OPFS option is selected by default
const opfsRadio = dialog.getByRole('radio', {
name: /Save in this browser/,
name: /this browser/i,
});
await expect(opfsRadio).toBeChecked();

Expand Down Expand Up @@ -578,14 +603,8 @@ 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)
const tempPlaygroundRow = website.page
.locator('[class*="siteRowContent"]')
.filter({ hasText: 'Temporary Playground' });

// The row exists but it's for creating a new temporary playground
await expect(tempPlaygroundRow).toBeVisible();
// Importing a ZIP from a saved site should never overwrite the saved site.
// The import should land in a temporary site (created or reused).

// Create a test ZIP
const importedMarker = 'FRESH_IMPORT_MARKER_BBBBB';
Expand Down
Loading