From cb1f2be7c397b9c7be5c8c4e4c1147afd70ccf7c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Sat, 6 Dec 2025 01:46:52 +0100 Subject: [PATCH] Always import into temporary Playground, never into saved sites When importing from GitHub or a ZIP file, we now ensure the import goes into a temporary Playground rather than overwriting a saved site. This protects saved sites from accidental data loss. For the GitHub import modal: Before showing the form, we check if the active site is saved. If so, we switch to an existing temporary site or create a new one. For ZIP import: When a file is selected while viewing a saved site, we store the file, switch to the temporary site, and then complete the import via a useEffect hook once the switch is complete. Added two e2e tests to verify: 1. Importing a ZIP while on a saved site switches to temporary site 2. Importing a ZIP when starting directly with a saved site slug creates a new temporary site for the import --- .../website/playwright/e2e/opfs.spec.ts | 425 ++++++++++++++++++ .../saved-playgrounds-overlay/index.tsx | 55 +++ .../src/github/github-import-form/modal.tsx | 51 +++ 3 files changed, 531 insertions(+) diff --git a/packages/playground/website/playwright/e2e/opfs.spec.ts b/packages/playground/website/playwright/e2e/opfs.spec.ts index bab869964f..9dd2ded8a7 100644 --- a/packages/playground/website/playwright/e2e/opfs.spec.ts +++ b/packages/playground/website/playwright/e2e/opfs.spec.ts @@ -2,6 +2,202 @@ import { test, expect } from '../playground-fixtures.ts'; import type { Blueprint } from '@wp-playground/blueprints'; import type { Page } from '@playwright/test'; +/** + * Creates a minimal WordPress export ZIP file for testing imports. + * The ZIP contains just an index.php file with the given marker content. + * + * This is a pre-built minimal ZIP structure created with the following layout: + * /wp-content/ + * index.php -> "> 1) | + (now.getMinutes() << 5) | + (now.getHours() << 11); + const dosDate = + now.getDate() | + ((now.getMonth() + 1) << 5) | + ((now.getFullYear() - 1980) << 9); + + // Calculate CRC32 for the content + const crc32 = calculateCrc32(phpBytes); + + // Build the ZIP file structure + const localFileHeader = Buffer.alloc(30 + filePathBytes.length); + let offset = 0; + + // Local file header signature + localFileHeader.writeUInt32LE(0x04034b50, offset); + offset += 4; + // Version needed to extract + localFileHeader.writeUInt16LE(20, offset); + offset += 2; + // General purpose bit flag + localFileHeader.writeUInt16LE(0, offset); + offset += 2; + // Compression method (0 = stored) + localFileHeader.writeUInt16LE(0, offset); + offset += 2; + // Last mod file time + localFileHeader.writeUInt16LE(dosTime, offset); + offset += 2; + // Last mod file date + localFileHeader.writeUInt16LE(dosDate, offset); + offset += 2; + // CRC-32 + localFileHeader.writeUInt32LE(crc32, offset); + offset += 4; + // Compressed size + localFileHeader.writeUInt32LE(phpBytes.length, offset); + offset += 4; + // Uncompressed size + localFileHeader.writeUInt32LE(phpBytes.length, offset); + offset += 4; + // File name length + localFileHeader.writeUInt16LE(filePathBytes.length, offset); + offset += 2; + // Extra field length + localFileHeader.writeUInt16LE(0, offset); + offset += 2; + // File name + filePathBytes.copy(localFileHeader, offset); + + // Central directory file header + const centralDirHeader = Buffer.alloc(46 + filePathBytes.length); + offset = 0; + + // Central file header signature + centralDirHeader.writeUInt32LE(0x02014b50, offset); + offset += 4; + // Version made by + centralDirHeader.writeUInt16LE(20, offset); + offset += 2; + // Version needed to extract + centralDirHeader.writeUInt16LE(20, offset); + offset += 2; + // General purpose bit flag + centralDirHeader.writeUInt16LE(0, offset); + offset += 2; + // Compression method + centralDirHeader.writeUInt16LE(0, offset); + offset += 2; + // Last mod file time + centralDirHeader.writeUInt16LE(dosTime, offset); + offset += 2; + // Last mod file date + centralDirHeader.writeUInt16LE(dosDate, offset); + offset += 2; + // CRC-32 + centralDirHeader.writeUInt32LE(crc32, offset); + offset += 4; + // Compressed size + centralDirHeader.writeUInt32LE(phpBytes.length, offset); + offset += 4; + // Uncompressed size + centralDirHeader.writeUInt32LE(phpBytes.length, offset); + offset += 4; + // File name length + centralDirHeader.writeUInt16LE(filePathBytes.length, offset); + offset += 2; + // Extra field length + centralDirHeader.writeUInt16LE(0, offset); + offset += 2; + // File comment length + centralDirHeader.writeUInt16LE(0, offset); + offset += 2; + // Disk number start + centralDirHeader.writeUInt16LE(0, offset); + offset += 2; + // Internal file attributes + centralDirHeader.writeUInt16LE(0, offset); + offset += 2; + // External file attributes + centralDirHeader.writeUInt32LE(0, offset); + offset += 4; + // Relative offset of local header + centralDirHeader.writeUInt32LE(0, offset); + offset += 4; + // File name + filePathBytes.copy(centralDirHeader, offset); + + // End of central directory record + const centralDirOffset = localFileHeader.length + phpBytes.length; + const centralDirSize = centralDirHeader.length; + + const endOfCentralDir = Buffer.alloc(22); + offset = 0; + + // End of central dir signature + endOfCentralDir.writeUInt32LE(0x06054b50, offset); + offset += 4; + // Number of this disk + endOfCentralDir.writeUInt16LE(0, offset); + offset += 2; + // Disk where central directory starts + endOfCentralDir.writeUInt16LE(0, offset); + offset += 2; + // Number of central directory records on this disk + endOfCentralDir.writeUInt16LE(1, offset); + offset += 2; + // Total number of central directory records + endOfCentralDir.writeUInt16LE(1, offset); + offset += 2; + // Size of central directory + endOfCentralDir.writeUInt32LE(centralDirSize, offset); + offset += 4; + // Offset of start of central directory + endOfCentralDir.writeUInt32LE(centralDirOffset, offset); + offset += 4; + // Comment length + endOfCentralDir.writeUInt16LE(0, offset); + + return Buffer.concat([ + localFileHeader, + phpBytes, + centralDirHeader, + endOfCentralDir, + ]); +} + +/** + * Simple CRC32 implementation for ZIP file creation + */ +function calculateCrc32(buffer: Buffer): number { + let crc = 0xffffffff; + const table = getCrc32Table(); + for (let i = 0; i < buffer.length; i++) { + crc = (crc >>> 8) ^ table[(crc ^ buffer[i]) & 0xff]; + } + return (crc ^ 0xffffffff) >>> 0; +} + +let crc32Table: number[] | null = null; +function getCrc32Table(): number[] { + if (crc32Table) return crc32Table; + crc32Table = []; + for (let i = 0; i < 256; i++) { + let c = i; + for (let j = 0; j < 8; j++) { + c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1; + } + crc32Table[i] = c; + } + return crc32Table; +} + // OPFS tests must run serially because OPFS storage is shared at the browser // level, so tests would interfere with each other's saved sites if run in parallel. test.describe.configure({ mode: 'serial' }); @@ -419,3 +615,232 @@ test('should display OPFS storage option as selected by default', async ({ // Close the modal await dialog.getByRole('button', { name: 'Cancel' }).click(); }); + +test('should import ZIP into temporary site when a saved site exists', async ({ + website, + wordpress, + browserName, +}) => { + test.skip( + browserName !== 'chromium', + `This test relies on OPFS which isn't available in Playwright's flavor of ${browserName}.` + ); + + // Start with a blueprint that writes a distinctive marker to distinguish the saved site + const savedSiteMarker = 'SAVED_SITE_CONTENT_MARKER_12345'; + const blueprint: Blueprint = { + landingPage: '/test-marker.php', + steps: [ + { + step: 'writeFile', + path: '/wordpress/test-marker.php', + data: ` { + await dialog.accept(); + }); + + // Upload the ZIP file + await fileInput.setInputFiles({ + name: 'test-import.zip', + mimeType: 'application/zip', + 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( + 'Temporary Playground', + { timeout: 30000 } + ); + + // Now verify the saved site still has the original content. + // Open the saved playgrounds overlay and switch to the saved site + await website.openSavedPlaygroundsOverlay(); + + await website.page + .locator('[class*="siteRowContent"]') + .filter({ hasText: savedSiteName }) + .click(); + + // Wait for the saved site to load + await expect(website.page.getByLabel('Playground title')).toContainText( + savedSiteName, + { timeout: 30000 } + ); + + // Navigate to the test marker page and verify the original content is intact + await website.wordpress().locator('body').waitFor(); + + // Use the playground to navigate to our test page + const playgroundViewport = website.page.frameLocator( + '#playground-viewport:visible,.playground-viewport:visible' + ); + await playgroundViewport + .locator('#wp') + .evaluate((iframe: HTMLIFrameElement) => { + iframe.contentWindow!.location.href = '/test-marker.php'; + }); + + // Verify the saved site still has the original marker (not the imported content) + await expect(wordpress.locator('body')).toContainText(savedSiteMarker, { + timeout: 10000, + }); +}); + +test('should create temporary site when importing ZIP while on a saved site with no existing temporary site', async ({ + website, + wordpress, + browserName, +}) => { + test.skip( + browserName !== 'chromium', + `This test relies on OPFS which isn't available in Playwright's flavor of ${browserName}.` + ); + + // First, create and save a site + const savedSiteMarker = 'SAVED_ONLY_MARKER_AAAAA'; + const blueprint: Blueprint = { + landingPage: '/saved-only-marker.php', + steps: [ + { + step: 'writeFile', + path: '/wordpress/saved-only-marker.php', + data: ` { + await dialog.accept(); + }); + + // Upload the ZIP file + await fileInput.setInputFiles({ + name: 'test-import-direct.zip', + mimeType: 'application/zip', + buffer: zipBuffer, + }); + + // The system should create a new temporary site and import into it. + // Wait for the title to show "Temporary Playground" + await expect(website.page.getByLabel('Playground title')).toContainText( + 'Temporary Playground', + { timeout: 60000 } + ); + + // Now verify the saved site is unchanged. + // Switch back to the saved site + await website.openSavedPlaygroundsOverlay(); + + await website.page + .locator('[class*="siteRowContent"]') + .filter({ hasText: savedSiteName }) + .click(); + + await expect(website.page.getByLabel('Playground title')).toContainText( + savedSiteName, + { timeout: 30000 } + ); + + // Navigate to the marker page + await website.wordpress().locator('body').waitFor(); + + const playgroundViewport = website.page.frameLocator( + '#playground-viewport:visible,.playground-viewport:visible' + ); + await playgroundViewport + .locator('#wp') + .evaluate((iframe: HTMLIFrameElement) => { + iframe.contentWindow!.location.href = '/saved-only-marker.php'; + }); + + // Verify the saved site still has the original marker + await expect(wordpress.locator('body')).toContainText(savedSiteMarker, { + timeout: 10000, + }); +}); 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 ece51312ae..23dc117ad1 100644 --- a/packages/playground/website/src/components/saved-playgrounds-overlay/index.tsx +++ b/packages/playground/website/src/components/saved-playgrounds-overlay/index.tsx @@ -116,6 +116,7 @@ export function SavedPlaygroundsOverlay({ const [searchQuery, setSearchQuery] = useState(''); const [selectedTag, setSelectedTag] = useState(null); const [isClosing, setIsClosing] = useState(false); + const [pendingZipFile, setPendingZipFile] = useState(null); const closeWithFade = (callback?: () => void) => { setIsClosing(true); @@ -127,10 +128,64 @@ export function SavedPlaygroundsOverlay({ }, 200); // Match the fadeOut animation duration }; + // Ensure we import into a temporary site, not a saved site. + // This effect handles the actual import once we're on a temporary site. + const isTemporarySite = activeSite?.metadata.storage === 'none'; + useEffect(() => { + if (!pendingZipFile || !isTemporarySite || !playground) { + return; + } + + const doImport = async () => { + try { + await importWordPressFiles(playground, { + wordPressFilesZip: pendingZipFile, + }); + // TODO: Do not prefetch update checks at this stage, it delays + // refreshing the page. + setTimeout(async () => { + await playground.goTo('/'); + }, 200); + alert( + 'File imported! This Playground instance has been updated and will refresh shortly.' + ); + onClose(); + } catch (error) { + logger.error(error); + alert( + 'Unable to import file. Is it a valid WordPress Playground export?' + ); + } finally { + setPendingZipFile(null); + // Reset the input so the same file can be selected again + if (zipFileInputRef.current) { + zipFileInputRef.current.value = ''; + } + } + }; + doImport(); + }, [pendingZipFile, isTemporarySite, playground, onClose]); + const handleImportZip = async (e: React.ChangeEvent) => { const file = e.target.files?.[0]; if (!file) return; + // Always import into a temporary site, never into a saved site. + // If we're on a saved site, switch to/create a temporary one first. + if (!isTemporarySite) { + setPendingZipFile(file); + if (temporarySite) { + // Switch to existing temporary site, then import will happen via effect + dispatch(setActiveSite(temporarySite.slug)); + } else { + // No temporary site exists, create one by redirecting. + // Note: This will cause a page reload, losing the file selection. + // We store it in state hoping the switch happens without redirect. + redirectTo(PlaygroundRoute.newTemporarySite()); + } + return; + } + if (!playground) { alert( 'No active Playground to import into. Please create one first.' 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 93398c6747..5d7923a741 100644 --- a/packages/playground/website/src/github/github-import-form/modal.tsx +++ b/packages/playground/website/src/github/github-import-form/modal.tsx @@ -1,10 +1,20 @@ +import { useEffect } from 'react'; 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 { + useAppSelector, + useActiveSite, + useAppDispatch, + setActiveSite, +} from '../../lib/state/redux/store'; +import { selectTemporarySite } from '../../lib/state/redux/slice-sites'; import { useDispatch } from 'react-redux'; import { Modal } from '../../components/modal'; +import { Spinner } from '@wordpress/components'; +import { PlaygroundRoute, redirectTo } from '../../lib/state/url/router'; interface GithubImportModalProps { defaultOpen?: boolean; @@ -15,11 +25,52 @@ export function GithubImportModal({ onImported, }: GithubImportModalProps) { const dispatch: PlaygroundDispatch = useDispatch(); + const appDispatch = useAppDispatch(); const playground = usePlaygroundClient(); + const activeSite = useActiveSite(); + const temporarySite = useAppSelector(selectTemporarySite); + + // Ensure we're importing into a temporary site, not a saved site. + // If the active site is saved, switch to or create a temporary site. + const isSavedSite = activeSite && activeSite.metadata.storage !== 'none'; + + useEffect(() => { + if (!isSavedSite) { + return; + } + if (temporarySite) { + // Switch to existing temporary site + appDispatch(setActiveSite(temporarySite.slug)); + } else { + // No temporary site exists, create one by redirecting + redirectTo(PlaygroundRoute.newTemporarySite()); + dispatch(setActiveModal(null)); + } + }, [isSavedSite, temporarySite, appDispatch, dispatch]); const closeModal = () => { dispatch(setActiveModal(null)); }; + + // Show loading while switching to temporary site + if (isSavedSite) { + return ( + +
+ + Switching to temporary Playground... +
+
+ ); + } + return (