diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1c8b611eb5..f08bf91640 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -43,22 +43,62 @@ jobs: with: name: cypress-screenshots path: dist/cypress/packages/playground/website/screenshots - test-e2e-playwright: + + test-e2e-playwright-prepare: runs-on: ubuntu-latest needs: [lint-and-typecheck] - # Run as root to allow node to bind to port 80 steps: - uses: actions/checkout@v3 - uses: ./.github/actions/prepare-playground - name: Install Playwright Browsers run: sudo npx playwright install --with-deps - - name: Run Playwright tests - run: sudo CI=true npx nx run playground-website:e2e:playwright:ci + - name: Prepare app deploy and offline mode + run: npx nx e2e:playwright:prepare-app-deploy-and-offline-mode playground-website + - name: Zip dist + run: zip -r dist.zip dist + - name: Upload dist + uses: actions/upload-artifact@v4 + with: + name: playwright-dist + path: dist.zip + test-e2e-playwright: + runs-on: ubuntu-latest + needs: [test-e2e-playwright-prepare] + strategy: + fail-fast: false + matrix: + part: ['chromium', 'firefox', 'webkit'] + steps: + - uses: actions/checkout@v3 + - uses: ./.github/actions/prepare-playground + - name: Download dist + uses: actions/download-artifact@v4 + with: + name: playwright-dist + - name: Unzip dist + run: unzip dist.zip + - name: Install Playwright Browser + run: sudo npx playwright install ${{ matrix.part }} --with-deps + - name: Run Playwright tests - ${{ matrix.part }} + run: | + if [ "${{ matrix.part }}" = "firefox" ]; then + sudo -E HOME=/root XDG_RUNTIME_DIR=/root CI=true npx playwright test --config=packages/playground/website/playwright/playwright.ci.config.ts --project=${{ matrix.part }} + else + sudo CI=true npx playwright test --config=packages/playground/website/playwright/playwright.ci.config.ts --project=${{ matrix.part }} + fi - uses: actions/upload-artifact@v4 if: ${{ !cancelled() }} with: - name: playwright-report - path: playwright-report/ + name: playwright-report-${{ matrix.part }} + path: packages/playground/website/playwright-report/ + if-no-files-found: ignore + - uses: actions/upload-artifact@v4 + if: ${{ !cancelled() }} + with: + name: playwright-snapshots-${{ matrix.part }} + path: packages/playground/website/playwright/e2e/deployment.spec.ts-snapshots/ + if-no-files-found: ignore + build: runs-on: ubuntu-latest needs: [lint-and-typecheck] diff --git a/package-lock.json b/package-lock.json index 8a980dda5e..458976e7e2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -47163,7 +47163,7 @@ }, "packages/php-wasm/cli": { "name": "@php-wasm/cli", - "version": "0.9.45", + "version": "0.9.44", "license": "GPL-2.0-or-later", "bin": { "cli": "php-wasm.js" @@ -47180,7 +47180,7 @@ }, "packages/php-wasm/fs-journal": { "name": "@php-wasm/fs-journal", - "version": "0.9.45", + "version": "0.9.44", "license": "GPL-2.0-or-later", "engines": { "node": ">=18.18.0", @@ -47189,7 +47189,7 @@ }, "packages/php-wasm/logger": { "name": "@php-wasm/logger", - "version": "0.9.45", + "version": "0.9.44", "license": "GPL-2.0-or-later", "engines": { "node": ">=18.18.0", @@ -47198,7 +47198,7 @@ }, "packages/php-wasm/node": { "name": "@php-wasm/node", - "version": "0.9.45", + "version": "0.9.44", "license": "GPL-2.0-or-later", "engines": { "node": ">=18.18.0", @@ -47207,12 +47207,12 @@ }, "packages/php-wasm/node-polyfills": { "name": "@php-wasm/node-polyfills", - "version": "0.9.45", + "version": "0.9.44", "license": "GPL-2.0-or-later" }, "packages/php-wasm/progress": { "name": "@php-wasm/progress", - "version": "0.9.45", + "version": "0.9.44", "license": "GPL-2.0-or-later", "engines": { "node": ">=18.18.0", @@ -47221,7 +47221,7 @@ }, "packages/php-wasm/scopes": { "name": "@php-wasm/scopes", - "version": "0.9.45", + "version": "0.9.44", "license": "GPL-2.0-or-later", "engines": { "node": ">=16.15.1", @@ -47230,12 +47230,12 @@ }, "packages/php-wasm/stream-compression": { "name": "@php-wasm/stream-compression", - "version": "0.9.45", + "version": "0.9.44", "license": "GPL-2.0-or-later" }, "packages/php-wasm/universal": { "name": "@php-wasm/universal", - "version": "0.9.45", + "version": "0.9.44", "license": "GPL-2.0-or-later", "engines": { "node": ">=18.18.0", @@ -47244,7 +47244,7 @@ }, "packages/php-wasm/util": { "name": "@php-wasm/util", - "version": "0.9.45", + "version": "0.9.44", "engines": { "node": ">=18.18.0", "npm": ">=8.11.0" @@ -47252,7 +47252,7 @@ }, "packages/php-wasm/web": { "name": "@php-wasm/web", - "version": "0.9.45", + "version": "0.9.44", "license": "GPL-2.0-or-later", "engines": { "node": ">=16.15.1", @@ -47261,7 +47261,7 @@ }, "packages/php-wasm/web-service-worker": { "name": "@php-wasm/web-service-worker", - "version": "0.9.45", + "version": "0.9.44", "license": "GPL-2.0-or-later", "engines": { "node": ">=18.18.0", @@ -47270,7 +47270,7 @@ }, "packages/playground/blueprints": { "name": "@wp-playground/blueprints", - "version": "0.9.45", + "version": "0.9.44", "engines": { "node": ">=18.18.0", "npm": ">=8.11.0" @@ -47278,7 +47278,7 @@ }, "packages/playground/cli": { "name": "@wp-playground/cli", - "version": "0.9.45", + "version": "0.9.44", "license": "GPL-2.0-or-later", "bin": { "cli": "wp-playground.js" @@ -47286,7 +47286,7 @@ }, "packages/playground/client": { "name": "@wp-playground/client", - "version": "0.9.45", + "version": "0.9.44", "license": "GPL-2.0-or-later", "engines": { "node": ">=18.18.0", @@ -47295,7 +47295,7 @@ }, "packages/playground/common": { "name": "@wp-playground/common", - "version": "0.9.45", + "version": "0.9.44", "license": "GPL-2.0-or-later", "engines": { "node": ">=18.18.0", @@ -47304,7 +47304,7 @@ }, "packages/playground/components": { "name": "@wp-playground/components", - "version": "0.9.45", + "version": "0.9.44", "engines": { "node": ">=18.18.0", "npm": ">=8.11.0" @@ -47331,7 +47331,7 @@ }, "packages/playground/storage": { "name": "@wp-playground/storage", - "version": "0.9.45", + "version": "0.9.44", "license": "GPL-2.0-or-later" }, "packages/playground/stream-compression": { @@ -47350,7 +47350,7 @@ }, "packages/playground/wordpress": { "name": "@wp-playground/wordpress", - "version": "0.9.45", + "version": "0.9.44", "license": "GPL-2.0-or-later", "engines": { "node": ">=18.18.0", diff --git a/packages/php-wasm/web-service-worker/src/index.ts b/packages/php-wasm/web-service-worker/src/index.ts index b1b33a207a..95ecc65ea4 100644 --- a/packages/php-wasm/web-service-worker/src/index.ts +++ b/packages/php-wasm/web-service-worker/src/index.ts @@ -1,2 +1,2 @@ -export * from './initialize-service-worker'; +export * from './utils'; export * from './messaging'; diff --git a/packages/php-wasm/web-service-worker/src/initialize-service-worker.spec.ts b/packages/php-wasm/web-service-worker/src/utils.spec.ts similarity index 90% rename from packages/php-wasm/web-service-worker/src/initialize-service-worker.spec.ts rename to packages/php-wasm/web-service-worker/src/utils.spec.ts index 81241325c0..f5e9c0bb06 100644 --- a/packages/php-wasm/web-service-worker/src/initialize-service-worker.spec.ts +++ b/packages/php-wasm/web-service-worker/src/utils.spec.ts @@ -1,4 +1,4 @@ -import { cloneRequest, getRequestHeaders } from './initialize-service-worker'; +import { cloneRequest, getRequestHeaders } from './utils'; describe('cloneRequest', () => { it('should clone request headers', async () => { diff --git a/packages/php-wasm/web-service-worker/src/initialize-service-worker.ts b/packages/php-wasm/web-service-worker/src/utils.ts similarity index 71% rename from packages/php-wasm/web-service-worker/src/initialize-service-worker.ts rename to packages/php-wasm/web-service-worker/src/utils.ts index 40be283e7a..c5da650b8b 100644 --- a/packages/php-wasm/web-service-worker/src/initialize-service-worker.ts +++ b/packages/php-wasm/web-service-worker/src/utils.ts @@ -4,71 +4,6 @@ declare const self: ServiceWorkerGlobalScope; import { awaitReply, getNextRequestId } from './messaging'; import { getURLScope, isURLScoped, setURLScope } from '@php-wasm/scopes'; -/** - * Run this function in the service worker to install the required event - * handlers. - * - * @param config - */ -export function initializeServiceWorker(config: ServiceWorkerConfiguration) { - const { handleRequest = defaultRequestHandler } = config; - - /** - * The main method. It captures the requests and loop them back to the - * Worker Thread using the Loopback request - */ - self.addEventListener('fetch', (event) => { - const url = new URL(event.request.url); - - // Don't handle requests to the service worker script itself. - if (url.pathname.startsWith(self.location.pathname)) { - return; - } - - // Only handle requests from scoped sites. - // So – bale out if the request URL is not scoped and the - // referrer URL is not scoped. - if (!isURLScoped(url)) { - let referrerUrl; - try { - referrerUrl = new URL(event.request.referrer); - } catch (e) { - return; - } - if (!isURLScoped(referrerUrl)) { - // Let the browser handle uncoped requests as is. - return; - } - } - const responsePromise = handleRequest(event); - if (responsePromise) { - event.respondWith(responsePromise); - } - }); -} - -async function defaultRequestHandler(event: FetchEvent) { - event.preventDefault(); - const url = new URL(event.request.url); - const workerResponse = await convertFetchEventToPHPRequest(event); - if ( - workerResponse.status === 404 && - (workerResponse.headers.get('x-backfill-from') === 'remote-host' || - // TODO: Remove this once it become clear we aren't reverting - // request routing changes - workerResponse.headers.get('x-file-type') === 'static') - ) { - const request = await cloneRequest(event.request, { - url, - // Omit credentials to avoid causing cache aborts due to presence of - // cookies - credentials: 'omit', - }); - return fetch(request); - } - return workerResponse; -} - export async function convertFetchEventToPHPRequest(event: FetchEvent) { let url = new URL(event.request.url); @@ -178,10 +113,6 @@ export async function broadcastMessageExpectReply(message: any, scope: string) { return requestId; } -interface ServiceWorkerConfiguration { - handleRequest?: (event: FetchEvent) => Promise | undefined; -} - /** * Copy a request with custom overrides. * diff --git a/packages/playground/remote/service-worker.ts b/packages/playground/remote/service-worker.ts index d092961e44..ac63e4d309 100644 --- a/packages/playground/remote/service-worker.ts +++ b/packages/playground/remote/service-worker.ts @@ -1,4 +1,95 @@ /// +/** + * Playground's service worker. Here's a rundown of non-obvious things that + * are happening in here: + * + * ## Playground must be upgraded as early as possible after a new release + * + * New service workers call .skipWaiting(), immediately claim all the clients + * that were controlled by the previous service worker, and forcibly refreshes + * them. + * + * Why? + * + * Because Playground fetches new resources asynchronously and on demand. However, + * deploying a new webapp version of the app destroys the resources referenced in + * the previous webapp version. Therefore, we can't allow the previous version + * to run when a new version becomes available. + * + * ### Push notifications + * + * It would be supremely useful to proactively notify the webapp after a fresh deployment. + * Playground doesn't do that yet but it likely will in the future. + * + * ## Caching strategy + * + * Playground relies on the **Cache only** strategy. It loads assets from + * the network, caches them, and serves them from the cache. The assumption + * is that all network requests yield the most recent version of the remote file. + * + * This helps us avoid the HTTP cache problem. + * + * ### Cache layers + * + * We're dealing with the following cache layers: + * + * * HTTP cache in the browser + * * CacheStorage in the service worker + * * Edge Cache on playground.wordpress.net + * + * ### HTTP cache in the browser + * + * This service worker skips the browser HTTP cache for all network requests. This is because + * the HTTP cache caused a particularly nasty problem in Playground deployments. + * + * Installing a new service worker purged the CacheStorage and requested a new set of assets + * from the network. However, some of these requests were served from the HTTP cache. As a + * result, Playground would start loading a mix of old and new assets and quickly error out. + * What made it worse is that this broken state was cached in CacheStorage, breaking Playground + * for weeks until the cache was refreshed. + * + * See https://github.com/WordPress/wordpress-playground/pull/1822 for more details. + * + * ### CacheStorage in the service worker + * + * This servive worker uses a **Cache only** strategy to ensure all the loaded assets + * come from the same webapp build. + * + * The **Cache only** strategy means Playground only loads each assets from + * the network once, caches it, and serves it from the cache from that point on. + * + * The only times Playground reaches to the network are: + * + * * Before the service worker is installed. + * * When the service worker is being activated. + * * On CacheStorage cache miss occurs. + * + * ### Edge Cache on playground.wordpress.net + * + * The remote server (playground.wordpress.net) has an Edge Cache that's populated with + * all static assets on every webapp deployment. All the assets served by playground.wordpress.net + * at any point in time come from the same build and are consistent with each other. The + * deployment process is atomic-ish so the server should never expose a mix of old and new + * assets. + * + * However, what if a new webapp version is deployed right when someone downloaded 10 out of + * 27 static assets required to boot Playground? + * + * Right now, they'd end up in an undefined state and likely see an error. Then, on a page refresh, + * they'd pick up a new service worker that would purge the stale assets and boot the new webapp + * version. + * + * This is not a big problem for now, but it's also not the best user experience. This can be + * eventually solved with push notifications. A new deployment would notify all the active + * clients to upgrade and pick up the new assets. + * + * ## Related resources + * + * * PR that turned off HTTP caching: https://github.com/WordPress/wordpress-playground/pull/1822 + * * Exploring all the cache layers: https://github.com/WordPress/wordpress-playground/issues/1774 + * * Cache only strategy: https://web.dev/articles/offline-cookbook#cache-only + * * Service worker caching and HTTP caching: https://web.dev/articles/service-worker-caching-and-http-caching + */ declare const self: ServiceWorkerGlobalScope; @@ -7,14 +98,18 @@ import { applyRewriteRules } from '@php-wasm/universal'; import { awaitReply, convertFetchEventToPHPRequest, - initializeServiceWorker, cloneRequest, broadcastMessageExpectReply, } from '@php-wasm/web-service-worker'; import { wordPressRewriteRules } from '@wp-playground/wordpress'; import { reportServiceWorkerMetrics } from '@php-wasm/logger'; - -import { OfflineModeCache } from './src/lib/offline-mode-cache'; +import { + cachedFetch, + cacheOfflineModeAssetsForCurrentRelease, + isCurrentServiceWorkerActive, + purgeEverythingFromPreviousRelease, + shouldCacheUrl, +} from './src/lib/offline-mode-cache'; if (!(self as any).document) { // Workaround: vite translates import.meta.url @@ -26,7 +121,37 @@ if (!(self as any).document) { } /** - * Ensures the very first Playground load is controlled by this service worker. + * Forces the browser to always use the latest service worker. + * + * Each service worker build contains a hardcoded `buildVersion` used to derive a cache key + * for offline-mode-cache. As long as the previous service worker is used, it will + * keep serving a stale version of Playground assets, e.g. `/index.html`, `php.wasm`, etc. + * + * This is problematic for two reasons: + * + * 1. Users won't receive critical bugfixes for up to 24 hours after they're released [1]. + * 2. Users will experience fatal crashes. Assets such as the WebAssembly PHP builds are + * loaded asynchronously using fetch() and import() functions. The specific URLs are + * hardcoded by the bundler at build time, e.g. the worker-thread.js file contains + * a call similar to `import("./assets/php_8_3-2286e20c.js")`. If the browser uses + * a stale version of the worker thread, it will try to import a JavaScript file + * that no longer exists. + * + * See also: https://github.com/WordPress/wordpress-playground/issues/105 + * + * [1] https://web.dev/articles/service-worker-lifecycle#updates + */ +self.addEventListener('install', (event) => { + event.waitUntil(self.skipWaiting()); +}); + +/** + * Ensures: + * + * * The very first Playground load is controlled by this service worker. + * * Other browser tabs are upgraded to the latest service worker. + * + * ## Initial load * * This is necessary because service workers don't control any pages loaded * before they are activated. This includes the page that actually registers @@ -42,39 +167,85 @@ if (!(self as any).document) { * registration. It shouldn't have unwanted side effects in our case. All these * pages would get controlled eventually anyway. * + * ## Upgrading other browser tabs + * + * This activation hook upgrades all the Playground browser tabs to the latest + * service worker version, and that service worker upgrades them the latest version + * of the webapp. + * + * The moment a new Playground version is deployed, the existing browser tabs + * won't be able to load assets from the network. The older Playground version + * they're running contains hardcoded URLs to assets that no longer exist on + * the server. + * * See: * * The service worker lifecycle https://web.dev/articles/service-worker-lifecycle * * Clients.claim() docs https://developer.mozilla.org/en-US/docs/Web/API/Clients/claim */ self.addEventListener('activate', function (event) { - event.waitUntil(self.clients.claim()); + async function doActivate() { + await self.clients.claim(); + + if (shouldCacheUrl(new URL(location.href))) { + await purgeEverythingFromPreviousRelease(); + cacheOfflineModeAssetsForCurrentRelease(); + } + + // Reload all clients that were controlled by the previous service worker + // so they can load the new version of the app without any stale assets + // whatsoever. + const windowClients = await self.clients.matchAll({ + type: 'window', + includeUncontrolled: true, + }); + + for (const client of windowClients) { + let url; + try { + url = new URL(client.url); + } catch (e) { + // Ignore + return; + } + + if ( + url.pathname.startsWith('/remote.html') || + url.pathname.startsWith('/scope:') + ) { + return; + } + + // @TODO: Store temporary sites in OPFS to avoid destroying in-memory + // changes in tabs that are already open. + client.navigate(client.url); + } + } + event.waitUntil(doActivate()); }); -/** - * Handle fetch() caching: - * - * * Put the initial fetch response in the cache - * * Serve the subsequent requests from the cache - */ self.addEventListener('fetch', (event) => { + if (!isCurrentServiceWorkerActive()) { + return; + } + const url = new URL(event.request.url); - /** - * Don't cache requests to the service worker script itself. - */ + // Don't handle requests to the service worker script itself. if (url.pathname.startsWith(self.location.pathname)) { return; } - /** - * Don't cache requests to scoped URLs or if the referrer URL is scoped. - * - * These requests are made to the PHP Worker Thread and are not static assets. - */ - if (isURLScoped(url)) { + const isReservedUrl = + url.pathname.startsWith('/plugin-proxy') || + url.pathname.startsWith('/client/index.js'); + if (isReservedUrl) { return; } + if (isURLScoped(url)) { + return event.respondWith(handleScopedRequest(event, getURLScope(url)!)); + } + let referrerUrl; try { referrerUrl = new URL(event.request.referrer); @@ -83,155 +254,130 @@ self.addEventListener('fetch', (event) => { } if (referrerUrl && isURLScoped(referrerUrl)) { + return event.respondWith( + handleScopedRequest(event, getURLScope(referrerUrl)!) + ); + } + + if (!shouldCacheUrl(new URL(event.request.url))) { + /** + * It's safe to use the regular `fetch` function here. + * + * This request won't be cached in the offline mode cache + * and there's no risk of the two caches interfering with + * each other. + * + * See service-worker.ts for more details. + */ return; } - /** - * Respond with cached assets if available. - * If the asset is not cached, fetch it from the network and cache it. - */ - event.respondWith( - cachePromise.then((cache) => cache.cachedFetch(event.request)) - ); + // Use Cache Only strategy to serve regular static assets. + return event.respondWith(cachedFetch(event.request)); }); -reportServiceWorkerMetrics(self); +/** + * A request to a PHP Worker Thread or to a regular static asset, + * but initiated by a scoped referer (e.g. fetch() from a block editor iframe). + */ +async function handleScopedRequest(event: FetchEvent, scope: string) { + const fullUrl = new URL(event.request.url); + const unscopedUrl = removeURLScope(fullUrl); + if (fullUrl.pathname.endsWith('/wp-includes/empty.html')) { + return emptyHtml(); + } -const cachePromise = OfflineModeCache.getInstance().then((cache) => { - /** - * For offline mode to work we need to cache all required assets. - * - * These assets are listed in the `/assets-required-for-offline-mode.json` - * file and contain JavaScript, CSS, and other assets required to load the - * site without making any network requests. - */ - cache.cacheOfflineModeAssets(); - - /** - * Remove outdated files from the cache. - * - * We cache data based on `buildVersion` which is updated whenever Playground - * is built. So when a new version of Playground is deployed, the service - * worker will remove the old cache and cache the new assets. - * - * If your build version doesn't change while developing locally check - * `buildVersionPlugin` for more details on how it's generated. - */ - cache.removeOutdatedFiles(); - - return cache; -}); + const workerResponse = await convertFetchEventToPHPRequest(event); -initializeServiceWorker({ - handleRequest(event) { - const fullUrl = new URL(event.request.url); - let scope = getURLScope(fullUrl); - if (!scope) { - try { - scope = getURLScope(new URL(event.request.referrer)); - } catch (e) { - // Ignore - } + if ( + workerResponse.status === 404 && + workerResponse.headers.get('x-backfill-from') === 'remote-host' + ) { + const { staticAssetsDirectory } = await getScopedWpDetails(scope!); + if (!staticAssetsDirectory) { + const plain404Response = workerResponse.clone(); + plain404Response.headers.delete('x-backfill-from'); + return plain404Response; } - const unscopedUrl = removeURLScope(fullUrl); - const isReservedUrl = - unscopedUrl.pathname.startsWith('/plugin-proxy') || - unscopedUrl.pathname.startsWith('/client/index.js'); - if (isReservedUrl) { - return; + + // If we get a 404 for a static file, try to fetch it from + // the from the static assets directory at the remote server. + const requestedUrl = new URL(event.request.url); + const resolvedUrl = removeURLScope(requestedUrl); + resolvedUrl.pathname = applyRewriteRules( + resolvedUrl.pathname, + wordPressRewriteRules + ); + if ( + // Vite dev server requests + !resolvedUrl.pathname.startsWith('/@fs') && + !resolvedUrl.pathname.startsWith('/assets') + ) { + resolvedUrl.pathname = `/${staticAssetsDirectory}${resolvedUrl.pathname}`; } - event.preventDefault(); - async function asyncHandler() { - if (fullUrl.pathname.endsWith('/wp-includes/empty.html')) { - return emptyHtml(); - } + const request = await cloneRequest(event.request, { + url: resolvedUrl, + // Omit credentials to avoid causing cache aborts due to presence of + // cookies + credentials: 'omit', + }); - const workerResponse = await convertFetchEventToPHPRequest(event); - if ( - workerResponse.status === 404 && - workerResponse.headers.get('x-backfill-from') === 'remote-host' - ) { - const { staticAssetsDirectory } = await getScopedWpDetails( - scope! - ); - if (!staticAssetsDirectory) { - const plain404Response = workerResponse.clone(); - plain404Response.headers.delete('x-backfill-from'); - return plain404Response; - } - - // If we get a 404 for a static file, try to fetch it from - // the from the static assets directory at the remote server. - const requestedUrl = new URL(event.request.url); - const resolvedUrl = removeURLScope(requestedUrl); - resolvedUrl.pathname = applyRewriteRules( - resolvedUrl.pathname, - wordPressRewriteRules - ); - if ( - // Vite dev server requests - !resolvedUrl.pathname.startsWith('/@fs') && - !resolvedUrl.pathname.startsWith('/assets') - ) { - resolvedUrl.pathname = `/${staticAssetsDirectory}${resolvedUrl.pathname}`; - } - const request = await cloneRequest(event.request, { - url: resolvedUrl, - // Omit credentials to avoid causing cache aborts due to presence of - // cookies - credentials: 'omit', - }); - return fetch(request).catch((e) => { - if (e?.name === 'TypeError') { - // This could be an ERR_HTTP2_PROTOCOL_ERROR that sometimes - // happen on playground.wordpress.net. Let's add a randomized - // delay and retry once - return new Promise((resolve) => { - setTimeout(() => { - resolve(fetch(request)); - }, Math.random() * 1500); - }) as Promise; - } - - // Otherwise let's just re-throw the error - throw e; - }); + /** + * Intentionally use fetch() over fetchFresh(). + * + * At this point we know this request very likely came from WordPress + * and is looking for a WordPress-related static asset. WordPress + * has its own caching strategies in place. We're going to pass this + * request to the remote server as it is and let WordPress manage its + * own HTTP caching. + */ + return fetch(request).catch((e) => { + if (e?.name === 'TypeError') { + // This could be an ERR_HTTP2_PROTOCOL_ERROR that sometimes + // happen on playground.wordpress.net. Let's add a randomized + // delay and retry once + return new Promise((resolve) => { + setTimeout( + () => resolve(fetch(request)), + Math.random() * 1500 + ); + }) as Promise; } - // Path the block-editor.js file to ensure the site editor's iframe - // inherits the service worker. - // @see controlledIframe below for more details. - if ( - // WordPress Core version of block-editor.js - unscopedUrl.pathname.endsWith( - '/wp-includes/js/dist/block-editor.js' - ) || - unscopedUrl.pathname.endsWith( - '/wp-includes/js/dist/block-editor.min.js' - ) || - // Gutenberg version of block-editor.js - unscopedUrl.pathname.endsWith('/build/block-editor/index.js') || - unscopedUrl.pathname.endsWith( - '/build/block-editor/index.min.js' - ) - ) { - const script = await workerResponse.text(); - const newScript = `${controlledIframe} ${script.replace( - /\(\s*"iframe",/, - '(__playground_ControlledIframe,' - )}`; - return new Response(newScript, { - status: workerResponse.status, - statusText: workerResponse.statusText, - headers: workerResponse.headers, - }); - } + // Otherwise let's just re-throw the error + throw e; + }); + } - return workerResponse; - } - return asyncHandler(); - }, -}); + // Path the block-editor.js file to ensure the site editor's iframe + // inherits the service worker. + // @see controlledIframe below for more details. + if ( + // WordPress Core version of block-editor.js + unscopedUrl.pathname.endsWith('/wp-includes/js/dist/block-editor.js') || + unscopedUrl.pathname.endsWith( + '/wp-includes/js/dist/block-editor.min.js' + ) || + // Gutenberg version of block-editor.js + unscopedUrl.pathname.endsWith('/build/block-editor/index.js') || + unscopedUrl.pathname.endsWith('/build/block-editor/index.min.js') + ) { + const script = await workerResponse.text(); + const newScript = `${controlledIframe} ${script.replace( + /\(\s*"iframe",/, + '(__playground_ControlledIframe,' + )}`; + return new Response(newScript, { + status: workerResponse.status, + statusText: workerResponse.statusText, + headers: workerResponse.headers, + }); + } + + return workerResponse; +} + +reportServiceWorkerMetrics(self); /** * Pair the site editor's nested iframe to the Service Worker. diff --git a/packages/playground/remote/src/lib/offline-mode-cache.ts b/packages/playground/remote/src/lib/offline-mode-cache.ts index 8c4034f8f4..e7a331abaa 100644 --- a/packages/playground/remote/src/lib/offline-mode-cache.ts +++ b/packages/playground/remote/src/lib/offline-mode-cache.ts @@ -5,98 +5,164 @@ import { buildVersion } from 'virtual:remote-config'; const CACHE_NAME_PREFIX = 'playground-cache'; const LATEST_CACHE_NAME = `${CACHE_NAME_PREFIX}-${buildVersion}`; -export class OfflineModeCache { - public cache: Cache; - private hostname = self.location.hostname; +// We save a top-level Promise because this module is imported by +// a Service Worker module which does not allow top-level await. +const promisedOfflineModeCache = caches.open(LATEST_CACHE_NAME); - private static instance?: OfflineModeCache; - - static async getInstance() { - if (!OfflineModeCache.instance) { - const cache = await caches.open(LATEST_CACHE_NAME); - OfflineModeCache.instance = new OfflineModeCache(cache); +export async function cachedFetch(request: Request): Promise { + const offlineModeCache = await promisedOfflineModeCache; + let response = await offlineModeCache.match(request, { + ignoreSearch: true, + }); + if (!response) { + /** + * Ensure the response is not coming from HTTP cache. + * + * We never want to put a stale asset in CacheStorage as + * that would break Playground. + * + * See service-worker.ts for more details. + */ + response = await fetchFresh(request); + if (response.ok) { + /** + * Confirm the current service worker is still active + * when the asset is fetched. Caching a stale request + * from a stale worker has no benefits. It only takes + * up space. + */ + if (isCurrentServiceWorkerActive()) { + await offlineModeCache.put(request, response.clone()); + } } - return OfflineModeCache.instance; } - private constructor(cache: Cache) { - this.cache = cache; - } + return response; +} - async removeOutdatedFiles() { - const keys = await caches.keys(); - const oldKeys = keys.filter( - (key) => - key.startsWith(CACHE_NAME_PREFIX) && key !== LATEST_CACHE_NAME - ); - return Promise.all(oldKeys.map((key) => caches.delete(key))); - } +/** + * For offline mode to work we need to cache all required assets. + * + * These assets are listed in the `/assets-required-for-offline-mode.json` + * file and contain JavaScript, CSS, and other assets required to load the + * site without making any network requests. + */ +export async function cacheOfflineModeAssetsForCurrentRelease(): Promise { + // Get the cache manifest and add all the files to the cache + const manifestResponse = await fetchFresh( + '/assets-required-for-offline-mode.json' + ); + const requiredOfflineAssetUrls = await manifestResponse.json(); + const urlsToCache = ['/', ...requiredOfflineAssetUrls]; + const websiteRequests = urlsToCache.map( + /** + * Ensure the response is not coming from HTTP cache. + * + * If it did, we'd risk mixing assets from different + * Playground builds and breaking the site. + * + * See service-worker.ts for more details. + */ + (url: string) => new Request(url, { cache: 'no-cache' }) + ); + const offlineModeCache = await promisedOfflineModeCache; + await offlineModeCache.addAll(websiteRequests); +} - async cachedFetch(request: Request): Promise { - if (!this.shouldCacheUrl(new URL(request.url))) { - return await fetch(request); - } +/** + * Remove outdated files from the cache. + * + * We cache data based on `buildVersion` which is updated whenever Playground + * is built. So when a new version of Playground is deployed, the service + * worker will remove the old cache and cache the new assets. + * + * If your build version doesn't change while developing locally check + * `buildVersionPlugin` for more details on how it's generated. + */ +export async function purgeEverythingFromPreviousRelease() { + // @TODO: Ensure an older service worker won't ever remove the assets of a newer service worker, + // even if this is accidentally called in the older worker. + const keys = await caches.keys(); + const oldKeys = keys.filter( + (key) => key.startsWith(CACHE_NAME_PREFIX) && key !== LATEST_CACHE_NAME + ); + return Promise.all(oldKeys.map((key) => caches.delete(key))); +} - let response = await this.cache.match(request, { ignoreSearch: true }); - if (!response) { - response = await fetch(request); - if (response.ok) { - await this.cache.put(request, response.clone()); - } - } +/** + * Answers whether a given URL has a response in the offline mode cache. + * Ignores the search part of the URL by default. + */ +export async function hasCachedResponse( + url: string, + queryOptions: CacheQueryOptions = { ignoreSearch: true } +): Promise { + const offlineModeCache = await promisedOfflineModeCache; + const cachedResponse = await offlineModeCache.match(url, queryOptions); + return !!cachedResponse; +} - return response; +export function shouldCacheUrl(url: URL) { + if (url.href.includes('wordpress-static.zip')) { + return true; + } + /** + * The development environment uses Vite which doesn't work offline because + * it dynamically generates assets. Check the README for offline development + * instructions. + */ + if ( + url.href.startsWith('http://127.0.0.1:5400/') || + url.href.startsWith('http://localhost:5400/') || + url.href.startsWith('https://playground.test/') || + url.pathname.startsWith('/website-server/') + ) { + return false; } - async cacheOfflineModeAssets(): Promise { - if (!this.shouldCacheUrl(new URL(location.href))) { - return; - } - - // Get the cache manifest and add all the files to the cache - const manifestResponse = await fetch( - '/assets-required-for-offline-mode.json' - ); - const websiteUrls = await manifestResponse.json(); - await this.cache.addAll([...websiteUrls, ...['/']]); + /** + * Don't cache scoped requests made to the PHP Worker Thread. + * They may be static assets, but they may also be PHP files. + * We can't tell by the URL, e.g. `/sitemap.xml` can be both. + */ + if (isURLScoped(url)) { + return false; } - private shouldCacheUrl(url: URL) { - if (url.href.includes('wordpress-static.zip')) { - return true; - } - /** - * The development environment uses Vite which doesn't work offline because - * it dynamically generates assets. Check the README for offline development - * instructions. - */ - if ( - url.href.startsWith('http://127.0.0.1:5400/') || - url.href.startsWith('http://localhost:5400/') || - url.href.startsWith('https://playground.test/') || - url.pathname.startsWith('/website-server/') - ) { - return false; - } + /** + * Don't cache responses generated by PHP files – they may + * change on every request. + */ + if (url.pathname.endsWith('.php')) { + return false; + } - /** - * Scoped URLs are requests made to the PHP Worker Thread. - * These requests are not cached because they are not static assets. - */ - if (isURLScoped(url)) { - return false; - } + /** + * Allow only requests to the same hostname to be cached. + */ + return self.location.hostname === url.hostname; +} - /** - * Don't cache PHP files because they are dynamic. - */ - if (url.pathname.endsWith('.php')) { - return false; - } +/** + * Fetches a resource and avoids stale responses from browser cache. + * + * @param resource The resource to fetch. + * @param init Optional object containing custom settings. + * @returns Promise + */ +function fetchFresh(resource: RequestInfo | URL, init?: RequestInit) { + return fetch(resource, { + ...init, + cache: 'no-cache', + }); +} - /** - * Allow only requests to the same hostname to be cached. - */ - return this.hostname === url.hostname; +export function isCurrentServiceWorkerActive() { + // @ts-ignore + // Firefox doesn't support serviceWorker.state + if (!('serviceWorker' in self) || !('state' in self.serviceWorker)) { + return true; } + // @ts-ignore + return self.serviceWorker.state === 'activated'; } diff --git a/packages/playground/remote/src/lib/worker-utils.ts b/packages/playground/remote/src/lib/worker-utils.ts index 8045362299..711d8ed931 100644 --- a/packages/playground/remote/src/lib/worker-utils.ts +++ b/packages/playground/remote/src/lib/worker-utils.ts @@ -3,7 +3,7 @@ import { PHPResponse, PHPProcessManager, PHP } from '@php-wasm/universal'; import { createSpawnHandler, joinPaths, phpVar } from '@php-wasm/util'; import { logger } from '@php-wasm/logger'; import { unzipFile } from '@wp-playground/common'; -import { OfflineModeCache } from './offline-mode-cache'; +import { hasCachedResponse } from './offline-mode-cache'; import { getLoadedWordPressVersion } from '@wp-playground/wordpress'; export function spawnHandlerFactory(processManager: PHPProcessManager) { @@ -223,11 +223,7 @@ export async function hasCachedStaticFilesRemovedFromMinifiedBuild(php: PHP) { if (!staticAssetsUrl) { return false; } - const cache = await OfflineModeCache.getInstance(); - const response = await cache.cache.match(staticAssetsUrl, { - ignoreSearch: true, - }); - return !!response; + return await hasCachedResponse(staticAssetsUrl); } /** diff --git a/packages/playground/website/bin/version-switching-server.ts b/packages/playground/website/bin/version-switching-server.ts new file mode 100644 index 0000000000..4736568d7d --- /dev/null +++ b/packages/playground/website/bin/version-switching-server.ts @@ -0,0 +1,19 @@ +import { startVersionSwitchingServer } from '../playwright/version-switching-server'; + +const [, , oldVersionDir, newVersionDir, port] = process.argv; + +if (!oldVersionDir || !newVersionDir || !port) { + console.error( + 'Usage: node version-switching-server.js ' + ); + process.exit(1); +} + +const server = await startVersionSwitchingServer({ + oldVersionDirectory: oldVersionDir, + newVersionDirectory: newVersionDir, + port: parseInt(port, 10), +}); + +server.switchToNewVersion(); +console.log('Version switching server started'); diff --git a/packages/playground/website/cypress/e2e/blueprints.cy.ts b/packages/playground/website/cypress/e2e/blueprints.cy.ts deleted file mode 100644 index 7e8701a0e9..0000000000 --- a/packages/playground/website/cypress/e2e/blueprints.cy.ts +++ /dev/null @@ -1,98 +0,0 @@ -import { Blueprint } from '@wp-playground/blueprints'; - -describe('Blueprints', () => { - it('should resolve nice permalinks (/%postname%/)', () => { - cy.visit( - '/#' + - JSON.stringify({ - landingPage: '/sample-page/', - steps: [ - { - step: 'setSiteOptions', - options: { - permalink_structure: '/%25postname%25/', // %25 is escaped "%" - }, - }, - { - step: 'runPHP', - code: `flush_rules(); - `, - }, - { - step: 'setSiteOptions', - options: { - blogname: 'test', - }, - }, - ], - }) - ); - cy.wordPressDocument().its('body').should('have.class', 'page'); - cy.wordPressDocument().its('body').should('contain', 'Sample Page'); - }); - - it('Landing page without the initial slash should work', () => { - const blueprint: Blueprint = { - landingPage: 'wp-admin/plugins.php', - login: true, - }; - cy.visit('/#' + JSON.stringify(blueprint)); - cy.wordPressDocument().its('body').should('contain.text', 'Plugins'); - }); - - it('enableMultisite step should enable a multisite', () => { - const blueprint: Blueprint = { - landingPage: '/', - steps: [{ step: 'enableMultisite' }], - }; - cy.visit('/#' + JSON.stringify(blueprint)); - cy.wordPressDocument().its('body').should('contain.text', 'My Sites'); - }); - - it('Base64-encoded Blueprints should work', () => { - const blueprint: Blueprint = { - landingPage: '/', - steps: [{ step: 'enableMultisite' }], - }; - cy.visit('/#' + btoa(JSON.stringify(blueprint))); - cy.wordPressDocument().its('body').should('contain.text', 'My Sites'); - }); - - it('wp-cli step should create a post', () => { - const blueprint: Blueprint = { - landingPage: '/wp-admin/post.php', - login: true, - steps: [ - { - step: 'wp-cli', - command: - "wp post create --post_title='Test post' --post_excerpt='Some content' --no-color", - }, - ], - }; - cy.visit('/#' + JSON.stringify(blueprint)); - cy.wordPressDocument() - .its('body') - .find('[aria-label="“Test post” (Edit)"]') - .should('exist'); - }); - - it('PHP Shutdown should work', () => { - const blueprint: Blueprint = { - landingPage: '/wp-admin/', - features: { networking: true }, - steps: [ - { step: 'login' }, - { - step: 'writeFile', - path: '/wordpress/wp-content/mu-plugins/rewrite.php', - data: " { }); it('should load WordPress 6.3 when requested', () => { - cy.visit('/?wp=6.3&url=/wp-admin'); + cy.visit('/?wp=6.3&url=/wp-admin/'); cy.wordPressDocument().find(`body.branch-6-3`).should('exist'); }); }); diff --git a/packages/playground/website/playwright/README.md b/packages/playground/website/playwright/README.md index fffa6f9605..1e00917415 100644 --- a/packages/playground/website/playwright/README.md +++ b/packages/playground/website/playwright/README.md @@ -50,3 +50,20 @@ You can use [this guide to set up a local Multisite.](https://wordpress.github.i ```bash PLAYWRIGHT_TEST_BASE_URL='https://playground.test/website-server/' npx nx run playground-website:e2e:playwright ``` + +## Deployment tests + +### Setup + +Deployment tests require a old and new version of Playground to be built. +This is done by running the following script: + +```bash +npx nx run playground-website:e2e:playwright:prepare-app-deploy-and-offline-mode +``` + +### Run + +```bash +npx nx run playground-website:e2e:playwright:deployment +``` diff --git a/packages/playground/website/playwright/deploy-e2e-old-release.zip b/packages/playground/website/playwright/deploy-e2e-old-release.zip new file mode 100644 index 0000000000..46bc032c23 Binary files /dev/null and b/packages/playground/website/playwright/deploy-e2e-old-release.zip differ diff --git a/packages/playground/website/playwright/e2e/blueprints.spec.ts b/packages/playground/website/playwright/e2e/blueprints.spec.ts index b309560cf8..2e38529249 100644 --- a/packages/playground/website/playwright/e2e/blueprints.spec.ts +++ b/packages/playground/website/playwright/e2e/blueprints.spec.ts @@ -42,3 +42,96 @@ test('enableMultisite step should re-activate the plugins', async ({ 'Deactivate' ); }); + +test('should resolve nice permalinks (/%postname%/)', async ({ + website, + wordpress, +}) => { + const blueprint: Blueprint = { + landingPage: '/sample-page/', + steps: [ + { + step: 'setSiteOptions', + options: { + permalink_structure: '/%25postname%25/', // %25 is escaped "%" + }, + }, + { + step: 'runPHP', + code: `flush_rules(); + `, + }, + { + step: 'setSiteOptions', + options: { + blogname: 'test', + }, + }, + ], + }; + + await website.goto(`/#${JSON.stringify(blueprint)}`); + const body = wordpress.locator('body'); + await expect(body).toContainText('Sample Page'); +}); + +test('Landing page without the initial slash should work', async ({ + website, + wordpress, +}) => { + const blueprint: Blueprint = { + landingPage: 'wp-admin/plugins.php', + login: true, + }; + await website.goto(`/#${JSON.stringify(blueprint)}`); + await expect(wordpress.locator('body')).toContainText('Plugins'); +}); + +test('enableMultisite step should enable a multisite', async ({ + website, + wordpress, +}) => { + const blueprint: Blueprint = { + landingPage: '/', + steps: [{ step: 'enableMultisite' }], + }; + await website.goto(`/#${JSON.stringify(blueprint)}`); + await expect(wordpress.locator('body')).toContainText('My Sites'); +}); + +test('wp-cli step should create a post', async ({ website, wordpress }) => { + const blueprint: Blueprint = { + landingPage: '/wp-admin/post.php', + login: true, + steps: [ + { + step: 'wp-cli', + command: + "wp post create --post_title='Test post' --post_excerpt='Some content' --no-color", + }, + ], + }; + await website.goto(`/#${JSON.stringify(blueprint)}`); + await expect( + wordpress.locator('body').locator('[aria-label="“Test post” (Edit)"]') + ).toBeVisible(); +}); + +test('PHP Shutdown should work', async ({ website, wordpress }) => { + const blueprint: Blueprint = { + landingPage: '/wp-admin/', + features: { networking: true }, + steps: [ + { step: 'login' }, + { + step: 'writeFile', + path: '/wordpress/wp-content/mu-plugins/rewrite.php', + data: "> | null = null; + +test.beforeEach(async () => { + server = await startServer({ + port, + oldVersionDirectory: path.join( + __dirname, + '../../../../../dist/packages/playground/wasm-wordpress-net-old' + ), + newVersionDirectory: path.join( + __dirname, + '../../../../../dist/packages/playground/wasm-wordpress-net-new' + ), + }); + server.switchToOldVersion(); + server.setHttpCacheEnabled(true); +}); + +test.afterEach(async () => { + if (server) { + server.kill(); + } +}); + +for (const cachingEnabled of [true, false]) { + test(`When a new website version is deployed, it should be loaded upon a regular page refresh (with HTTP caching ${ + cachingEnabled ? 'enabled' : 'disabled' + })`, async ({ website, page }) => { + server!.setHttpCacheEnabled(cachingEnabled); + + await page.goto(url); + await website.waitForNestedIframes(); + await expect(page).toHaveScreenshot('website-old.png', { + maxDiffPixels, + }); + + server!.switchToNewVersion(); + await page.goto(url); + await website.waitForNestedIframes(); + await expect(page).toHaveScreenshot('website-new.png', { + maxDiffPixels, + }); + }); +} + +test( + 'When a new website version is deployed while the old version is still opened in two browser tabs, ' + + 'both tabs should be upgraded to the new app version upon a regular page refresh', + async ({ website, page, browser, browserName }) => { + test.skip( + browserName === 'webkit', + `Playwright creates separate ephemeral browser contexts for each Safari page, ` + + `which means they don't actually share the service worker and the first tab won't` + + `be refreshed when the second tab updates its service worker registration.` + ); + await page.goto(url); + await website.waitForNestedIframes(); + await expect(page).toHaveScreenshot('website-old.png', { + maxDiffPixels, + }); + + const page2 = await browser.newPage(); + await page2.goto(url); + await website.waitForNestedIframes(page2); + await expect(page2).toHaveScreenshot('website-old.png', { + maxDiffPixels, + }); + + server!.switchToNewVersion(); + await page.goto(url); + await website.waitForNestedIframes(page); + + await expect(page).toHaveScreenshot('website-new.png', { + maxDiffPixels, + }); + + await website.waitForNestedIframes(page2); + await expect(page2).toHaveScreenshot('website-new.png', { + maxDiffPixels, + }); + } +); + +test('offline mode – the app should load even when the server goes offline', async ({ + website, + page, + browserName, +}) => { + test.skip( + browserName === 'webkit', + `Playwright creates ephemeral browser contexts for each test, which causes the ` + + `test to fail in Safari. Tl;dr Safari only allows OPFS access in regular, non-incognito ` + + `browser tabs. See https://github.com/microsoft/playwright/issues/18235` + ); + test.skip( + browserName === 'firefox', + `Playground's offline mode doesn't work in Firefox yet. ` + + `See https://github.com/WordPress/wordpress-playground/issues/1645` + ); + + server!.switchToNewVersion(); + + await page.goto(`${url}`); + await website.waitForNestedIframes(); + // @TODO a better check – screenshot comparisons will be annoying to maintain + await expect(page).toHaveScreenshot('website-online.png', { + maxDiffPixels, + }); + + server!.kill(); + await page.reload(); + await website.waitForNestedIframes(); + await expect(page).toHaveScreenshot('website-online.png', { + maxDiffPixels, + }); +}); diff --git a/packages/playground/website/playwright/e2e/deployment.spec.ts-snapshots/website-new-chromium-linux.png b/packages/playground/website/playwright/e2e/deployment.spec.ts-snapshots/website-new-chromium-linux.png new file mode 100644 index 0000000000..4c86e0e273 Binary files /dev/null and b/packages/playground/website/playwright/e2e/deployment.spec.ts-snapshots/website-new-chromium-linux.png differ diff --git a/packages/playground/website/playwright/e2e/deployment.spec.ts-snapshots/website-new-firefox-linux.png b/packages/playground/website/playwright/e2e/deployment.spec.ts-snapshots/website-new-firefox-linux.png new file mode 100644 index 0000000000..5688dc9caf Binary files /dev/null and b/packages/playground/website/playwright/e2e/deployment.spec.ts-snapshots/website-new-firefox-linux.png differ diff --git a/packages/playground/website/playwright/e2e/deployment.spec.ts-snapshots/website-new-webkit-linux.png b/packages/playground/website/playwright/e2e/deployment.spec.ts-snapshots/website-new-webkit-linux.png new file mode 100644 index 0000000000..9cba30f989 Binary files /dev/null and b/packages/playground/website/playwright/e2e/deployment.spec.ts-snapshots/website-new-webkit-linux.png differ diff --git a/packages/playground/website/playwright/e2e/deployment.spec.ts-snapshots/website-old-chromium-linux.png b/packages/playground/website/playwright/e2e/deployment.spec.ts-snapshots/website-old-chromium-linux.png new file mode 100644 index 0000000000..32a9f067a9 Binary files /dev/null and b/packages/playground/website/playwright/e2e/deployment.spec.ts-snapshots/website-old-chromium-linux.png differ diff --git a/packages/playground/website/playwright/e2e/deployment.spec.ts-snapshots/website-old-firefox-linux.png b/packages/playground/website/playwright/e2e/deployment.spec.ts-snapshots/website-old-firefox-linux.png new file mode 100644 index 0000000000..179374f5d9 Binary files /dev/null and b/packages/playground/website/playwright/e2e/deployment.spec.ts-snapshots/website-old-firefox-linux.png differ diff --git a/packages/playground/website/playwright/e2e/deployment.spec.ts-snapshots/website-old-webkit-linux.png b/packages/playground/website/playwright/e2e/deployment.spec.ts-snapshots/website-old-webkit-linux.png new file mode 100644 index 0000000000..26d40eae3b Binary files /dev/null and b/packages/playground/website/playwright/e2e/deployment.spec.ts-snapshots/website-old-webkit-linux.png differ diff --git a/packages/playground/website/playwright/e2e/deployment.spec.ts-snapshots/website-online-chromium-linux.png b/packages/playground/website/playwright/e2e/deployment.spec.ts-snapshots/website-online-chromium-linux.png new file mode 100644 index 0000000000..4c86e0e273 Binary files /dev/null and b/packages/playground/website/playwright/e2e/deployment.spec.ts-snapshots/website-online-chromium-linux.png differ diff --git a/packages/playground/website/playwright/e2e/query-api.spec.ts b/packages/playground/website/playwright/e2e/query-api.spec.ts index 5c1af65880..44240e4e1b 100644 --- a/packages/playground/website/playwright/e2e/query-api.spec.ts +++ b/packages/playground/website/playwright/e2e/query-api.spec.ts @@ -35,7 +35,7 @@ test('should load WordPress 6.3 when requested', async ({ website, wordpress, }) => { - await website.goto('./?wp=6.3&url=/wp-admin'); + await website.goto('./?wp=6.3&url=/wp-admin/'); await expect(wordpress.locator(`body.branch-6-3`)).toContainText( 'Dashboard' ); diff --git a/packages/playground/website/playwright/e2e/website-ui.spec.ts b/packages/playground/website/playwright/e2e/website-ui.spec.ts index 7b7636a1e2..d866b7a782 100644 --- a/packages/playground/website/playwright/e2e/website-ui.spec.ts +++ b/packages/playground/website/playwright/e2e/website-ui.spec.ts @@ -12,10 +12,18 @@ import * as MinifiedWordPressVersions from '../../../wordpress-builds/src/wordpr test('should reflect the URL update from the navigation bar in the WordPress site', async ({ website, }) => { - await website.goto('./?url=/wp-admin'); - + await website.goto('./?url=/wp-admin/'); await website.ensureSiteViewIsExpanded(); + await expect(website.page.locator('input[value="/wp-admin/"]')).toHaveValue( + '/wp-admin/' + ); +}); +test('should correctly load /wp-admin without the trailing slash', async ({ + website, +}) => { + await website.goto('./?url=/wp-admin'); + await website.ensureSiteViewIsExpanded(); await expect(website.page.locator('input[value="/wp-admin/"]')).toHaveValue( '/wp-admin/' ); @@ -56,16 +64,13 @@ SupportedPHPVersions.forEach(async (version) => { return; } await website.goto(`./`); - await website.openForkPlaygroundSettings(); - await website.selectPHPVersion(version); - await website.clickSaveInForkPlaygroundSettings(); - await expect( - await website.getSiteInfoRowLocator('PHP version') - ).toHaveText(`${version} (with extensions)`); + await expect(website.getSiteInfoRowLocator('PHP version')).toHaveText( + `${version} (with extensions)` + ); }); test(`should not load additional PHP ${version} extensions when not requested`, async ({ @@ -83,9 +88,9 @@ SupportedPHPVersions.forEach(async (version) => { await website.clickSaveInForkPlaygroundSettings(); - await expect( - await website.getSiteInfoRowLocator('PHP version') - ).toHaveText(version); + await expect(website.getSiteInfoRowLocator('PHP version')).toHaveText( + version + ); }); }); @@ -102,7 +107,7 @@ Object.keys(MinifiedWordPressVersions) await website.clickSaveInForkPlaygroundSettings(); await expect( - await website.getSiteInfoRowLocator('WordPress version') + website.getSiteInfoRowLocator('WordPress version') ).toHaveText(version); }); }); @@ -111,10 +116,10 @@ test('should display networking as inactive by default', async ({ website, }) => { await website.goto('./'); - await website.ensureSiteManagerIsOpen(); - - await expect(await website.hasNetworkingEnabled()).toBeFalsy(); + await expect(website.getSiteInfoRowLocator('Network access')).toContainText( + 'No' + ); }); test('should display networking as active when networking is enabled', async ({ @@ -122,7 +127,9 @@ test('should display networking as active when networking is enabled', async ({ }) => { await website.goto('./?networking=yes'); await website.ensureSiteManagerIsOpen(); - await expect(await website.hasNetworkingEnabled()).toBeTruthy(); + await expect(website.getSiteInfoRowLocator('Network access')).toContainText( + 'Yes' + ); }); test('should enable networking when requested', async ({ website }) => { @@ -132,7 +139,9 @@ test('should enable networking when requested', async ({ website }) => { await website.setNetworkingEnabled(true); await website.clickSaveInForkPlaygroundSettings(); - await expect(await website.hasNetworkingEnabled()).toBeTruthy(); + await expect(website.getSiteInfoRowLocator('Network access')).toContainText( + 'Yes' + ); }); test('should disable networking when requested', async ({ website }) => { @@ -142,7 +151,9 @@ test('should disable networking when requested', async ({ website }) => { await website.setNetworkingEnabled(false); await website.clickSaveInForkPlaygroundSettings(); - await expect(await website.hasNetworkingEnabled()).toBeFalsy(); + await expect(website.getSiteInfoRowLocator('Network access')).toContainText( + 'No' + ); }); test('should display PHP output even when a fatal error is hit', async ({ diff --git a/packages/playground/website/playwright/playground-fixtures.ts b/packages/playground/website/playwright/playground-fixtures.ts index be7b63f678..70817343d9 100644 --- a/packages/playground/website/playwright/playground-fixtures.ts +++ b/packages/playground/website/playwright/playground-fixtures.ts @@ -11,7 +11,9 @@ export const test = base.extend({ const wpPage = page /* There are multiple viewports possible, so we need to select the one that is visible. */ - .frameLocator('.playground-viewport:visible') + .frameLocator( + '#playground-viewport:visible,.playground-viewport:visible' + ) .frameLocator('#wp'); await use(wpPage); }, diff --git a/packages/playground/website/playwright/playwright.config.ts b/packages/playground/website/playwright/playwright.config.ts index 7b9e8a5783..a74e2544f7 100644 --- a/packages/playground/website/playwright/playwright.config.ts +++ b/packages/playground/website/playwright/playwright.config.ts @@ -10,12 +10,10 @@ export const playwrightConfig: PlaywrightTestConfig = { fullyParallel: false, /* Fail the build on CI if you accidentally left test.only in the source code. */ forbidOnly: !!process.env.CI, - /* Retry on CI only */ - retries: process.env.CI ? 2 : 0, - /* Opt out of parallel tests on CI. */ - workers: process.env.CI ? 1 : 3, + retries: 0, + workers: 3, /* Reporter to use. See https://playwright.dev/docs/test-reporters */ - reporter: 'html', + reporter: [['html'], ['list', { printSteps: true }]], /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ use: { /* Base URL to use in actions like `await page.goto('/')`. */ @@ -36,15 +34,15 @@ export const playwrightConfig: PlaywrightTestConfig = { use: { ...devices['Desktop Chrome'] }, }, - // { - // name: 'firefox', - // use: { ...devices['Desktop Firefox'] }, - // }, + { + name: 'firefox', + use: { ...devices['Desktop Firefox'] }, + }, - // { - // name: 'webkit', - // use: { ...devices['Desktop Safari'] }, - // }, + { + name: 'webkit', + use: { ...devices['Desktop Safari'] }, + }, /* Test against mobile viewports. */ // { @@ -52,8 +50,8 @@ export const playwrightConfig: PlaywrightTestConfig = { // use: { ...devices['Pixel 5'] }, // }, // { - // name: 'Mobile Safari', - // use: { ...devices['iPhone 12'] }, + // name: 'Mobile Safari', + // use: { ...devices['iPhone 12'] }, // }, /* Test against branded browsers. */ diff --git a/packages/playground/website/playwright/version-switching-server.ts b/packages/playground/website/playwright/version-switching-server.ts new file mode 100644 index 0000000000..30955883bd --- /dev/null +++ b/packages/playground/website/playwright/version-switching-server.ts @@ -0,0 +1,75 @@ +/* eslint-disable no-console */ +import http from 'http'; +import express from 'express'; +import path from 'path'; +import process from 'process'; + +export async function startVersionSwitchingServer({ + port = 7999, + oldVersionDirectory, + newVersionDirectory, +}) { + const app = express(); + + if (!path.isAbsolute(oldVersionDirectory)) { + oldVersionDirectory = path.resolve(oldVersionDirectory); + } + if (!path.isAbsolute(newVersionDirectory)) { + newVersionDirectory = path.resolve(newVersionDirectory); + } + + let staticDirectory = oldVersionDirectory; + let httpCacheEnabled = true; + + const noCacheMiddleware = (req, res, next) => { + if (!httpCacheEnabled) { + res.setHeader( + 'Cache-Control', + 'no-cache, no-store, must-revalidate' + ); + res.setHeader('Pragma', 'no-cache'); + res.setHeader('Expires', '0'); + } + next(); + }; + + app.use(noCacheMiddleware); + + app.use((req, res, next) => { + express.static(staticDirectory)(req, res, next); + }); + + const server = await new Promise((resolve, reject) => { + const _server = app.listen(port, () => { + resolve(_server!); + }); + }); + + const sigintHandler = () => { + server.close(); + }; + + const sigtermHandler = () => { + server.close(); + }; + + process.on('SIGINT', sigintHandler); + process.on('SIGTERM', sigtermHandler); + + return { + switchToNewVersion: () => { + staticDirectory = newVersionDirectory; + }, + switchToOldVersion: () => { + staticDirectory = oldVersionDirectory; + }, + setHttpCacheEnabled: (enabled: boolean) => { + httpCacheEnabled = enabled; + }, + kill: () => { + server.close(); + process.off('SIGINT', sigintHandler); + process.off('SIGTERM', sigtermHandler); + }, + }; +} diff --git a/packages/playground/website/playwright/website-page.ts b/packages/playground/website/playwright/website-page.ts index 5eb95f8cd9..dcf0cd3804 100644 --- a/packages/playground/website/playwright/website-page.ts +++ b/packages/playground/website/playwright/website-page.ts @@ -1,15 +1,17 @@ -import { Page, expect, Locator } from '@playwright/test'; +import { Page, expect } from '@playwright/test'; export class WebsitePage { constructor(public readonly page: Page) {} // Wait for WordPress to load - async waitForNestedIframes() { + async waitForNestedIframes(page = this.page) { await expect( - await this.page + page /* There are multiple viewports possible, so we need to select the one that is visible. */ - .frameLocator('.playground-viewport:visible') + .frameLocator( + '#playground-viewport:visible,.playground-viewport:visible' + ) .frameLocator('#wp') .locator('body') ).not.toBeEmpty(); @@ -26,7 +28,7 @@ export class WebsitePage { if (!(await siteManagerHeading.isVisible({ timeout: 5000 }))) { await this.page.getByLabel('Open Site Manager').click(); } - expect(await siteManagerHeading.isVisible()).toBeTruthy(); + await expect(siteManagerHeading).toBeVisible(); } async ensureSiteViewIsExpanded() { @@ -36,7 +38,7 @@ export class WebsitePage { } const siteManagerHeading = this.page.getByText('Your Playgrounds'); - expect(await siteManagerHeading.isVisible()).toBeFalsy(); + await expect(siteManagerHeading).not.toBeVisible(); } async openNewSiteModal() { @@ -98,7 +100,7 @@ export class WebsitePage { await wordpressVersionSelect.selectOption(version); } - async getSiteInfoRowLocator(key: string): Promise { + getSiteInfoRowLocator(key: string) { return this.page.getByLabel(key); } @@ -110,11 +112,4 @@ export class WebsitePage { await checkbox.uncheck(); } } - - async hasNetworkingEnabled(): Promise { - const networkAccessText = await ( - await this.getSiteInfoRowLocator('Network access') - ).innerText(); - return networkAccessText === 'Yes'; - } } diff --git a/packages/playground/website/project.json b/packages/playground/website/project.json index ca84f60b7c..b1dac65d32 100644 --- a/packages/playground/website/project.json +++ b/packages/playground/website/project.json @@ -147,7 +147,8 @@ "options": { "commands": [ "npx playwright test --config=packages/playground/website/playwright/playwright.config.ts" - ] + ], + "parallel": false } }, "e2e:playwright:ci": { @@ -155,7 +156,52 @@ "options": { "commands": [ "npx playwright test --config=packages/playground/website/playwright/playwright.ci.config.ts" + ], + "parallel": false + } + }, + "e2e:playwright:prepare-app-deploy-and-offline-mode": { + "executor": "nx:noop", + "dependsOn": [ + "e2e:playwright:prepare-app-deploy-and-offline-mode:build-current-version", + "e2e:playwright:prepare-app-deploy-and-offline-mode:unzip-old-version" + ] + }, + "e2e:playwright:prepare-app-deploy-and-offline-mode:unzip-old-version": { + "executor": "nx:run-commands", + "options": { + "commands": [ + "unzip ./packages/playground/website/playwright/deploy-e2e-old-release.zip" ] + } + }, + "e2e:playwright:prepare-app-deploy-and-offline-mode:build-old-version": { + "executor": "nx:run-commands", + "options": { + "commands": [ + "export CURRENT_BRANCH=$(git rev-parse --abbrev-ref HEAD)", + "git fetch origin tag v0.9.0 --no-tags", + "git checkout tags/v0.9.0 -b v0.9.0", + "npm install", + "npm run build", + "PLAYGROUND_URL=http://localhost:7999 npx nx run playground-website:build:wasm-wordpress-net", + "rm -rf dist/packages/playground/wasm-wordpress-net-old || true", + "mv dist/packages/playground/wasm-wordpress-net dist/packages/playground/wasm-wordpress-net-old", + "git stash", + "git checkout $CURRENT_BRANCH", + "npm install" + ], + "parallel": false + } + }, + "e2e:playwright:prepare-app-deploy-and-offline-mode:build-current-version": { + "executor": "nx:run-commands", + "options": { + "commands": [ + "rm -rf dist/packages/playground/wasm-wordpress-net-new || true", + "cp -rf dist/packages/playground/wasm-wordpress-net dist/packages/playground/wasm-wordpress-net-new" + ], + "parallel": false }, "dependsOn": ["build"] } diff --git a/packages/playground/website/src/components/ensure-playground-site/ensure-playground-site-is-selected.tsx b/packages/playground/website/src/components/ensure-playground-site/ensure-playground-site-is-selected.tsx index 3a29b54c1d..12335106f1 100644 --- a/packages/playground/website/src/components/ensure-playground-site/ensure-playground-site-is-selected.tsx +++ b/packages/playground/website/src/components/ensure-playground-site/ensure-playground-site-is-selected.tsx @@ -44,7 +44,12 @@ export function EnsurePlaygroundSiteIsSelected({ ); useEffect(() => { - opfsSiteStorage?.list().then( + if (!opfsSiteStorage) { + logger.error('Error loading sites: OPFS not available'); + dispatch(siteListingLoaded([])); + return; + } + opfsSiteStorage.list().then( (sites) => dispatch(siteListingLoaded(sites)), (error) => { logger.error('Error loading sites:', error); diff --git a/packages/playground/website/src/components/site-manager/site-persist-button/index.tsx b/packages/playground/website/src/components/site-manager/site-persist-button/index.tsx index 2ffe9c910a..611b27bede 100644 --- a/packages/playground/website/src/components/site-manager/site-persist-button/index.tsx +++ b/packages/playground/website/src/components/site-manager/site-persist-button/index.tsx @@ -10,6 +10,7 @@ import css from './style.module.css'; import { persistTemporarySite } from '../../../lib/state/redux/persist-temporary-site'; import { selectClientInfoBySiteSlug } from '../../../lib/state/redux/slice-clients'; import { useLocalFsAvailability } from '../../../lib/hooks/use-local-fs-availability'; +import { isOpfsAvailable } from '../../../lib/state/opfs/opfs-site-storage'; export function SitePersistButton({ siteSlug, @@ -29,6 +30,7 @@ export function SitePersistButton({ <> dispatch(persistTemporarySite(siteSlug, 'opfs')) } @@ -36,6 +38,13 @@ export function SitePersistButton({ Save in this browser + {!isOpfsAvailable && ( + + {localFsAvailability === 'not-available' + ? 'Not available in this browser' + : 'Not available on this site'} + + )}