diff --git a/packages/astro/e2e/csp-client-only.test.js b/packages/astro/e2e/csp-client-only.test.js
new file mode 100644
index 000000000000..89f9ec4b9355
--- /dev/null
+++ b/packages/astro/e2e/csp-client-only.test.js
@@ -0,0 +1,126 @@
+import { expect } from '@playwright/test';
+import { testFactory } from './test-utils.js';
+
+const test = testFactory(import.meta.url, {
+ root: './fixtures/client-only/',
+ experimental: {
+ csp: true,
+ },
+});
+
+let previewServer;
+
+test.beforeAll(async ({ astro }) => {
+ await astro.build();
+ previewServer = await astro.preview();
+});
+
+test.afterAll(async () => {
+ await previewServer.stop();
+});
+test.describe('CSP Client only', () => {
+ test('React counter', async ({ astro, page }) => {
+ await page.goto(astro.resolveUrl('/'));
+
+ const counter = await page.locator('#react-counter');
+ await expect(counter, 'component is visible').toBeVisible();
+
+ const fallback = await page.locator('[data-fallback=react]');
+ await expect(fallback, 'fallback content is hidden').not.toBeVisible();
+
+ const count = await counter.locator('pre');
+ await expect(count, 'initial count is 0').toHaveText('0');
+
+ const children = await counter.locator('.children');
+ await expect(children, 'children exist').toHaveText('react');
+
+ const increment = await counter.locator('.increment');
+ await increment.click();
+
+ await expect(count, 'count incremented by 1').toHaveText('1');
+ });
+
+ test('Preact counter', async ({ astro, page }) => {
+ await page.goto(astro.resolveUrl('/'));
+
+ const counter = await page.locator('#preact-counter');
+ await expect(counter, 'component is visible').toBeVisible();
+
+ const fallback = await page.locator('[data-fallback=preact]');
+ await expect(fallback, 'fallback content is hidden').not.toBeVisible();
+
+ const count = await counter.locator('pre');
+ await expect(count, 'initial count is 0').toHaveText('0');
+
+ const children = await counter.locator('.children');
+ await expect(children, 'children exist').toHaveText('preact');
+
+ const increment = await counter.locator('.increment');
+ await increment.click();
+
+ await expect(count, 'count incremented by 1').toHaveText('1');
+ });
+
+ test('Solid counter', async ({ astro, page }) => {
+ await page.goto(astro.resolveUrl('/'));
+
+ const counter = await page.locator('#solid-counter');
+ await expect(counter, 'component is visible').toBeVisible();
+
+ const fallback = await page.locator('[data-fallback=solid]');
+ await expect(fallback, 'fallback content is hidden').not.toBeVisible();
+
+ const count = await counter.locator('pre');
+ await expect(count, 'initial count is 0').toHaveText('0');
+
+ const children = await counter.locator('.children');
+ await expect(children, 'children exist').toHaveText('solid');
+
+ const increment = await counter.locator('.increment');
+ await increment.click();
+
+ await expect(count, 'count incremented by 1').toHaveText('1');
+ });
+
+ test('Vue counter', async ({ astro, page }) => {
+ await page.goto(astro.resolveUrl('/'));
+
+ const counter = await page.locator('#vue-counter');
+ await expect(counter, 'component is visible').toBeVisible();
+
+ const fallback = await page.locator('[data-fallback=vue]');
+ await expect(fallback, 'fallback content is hidden').not.toBeVisible();
+
+ const count = await counter.locator('pre');
+ await expect(count, 'initial count is 0').toHaveText('0');
+
+ const children = await counter.locator('.children');
+ await expect(children, 'children exist').toHaveText('vue');
+
+ const increment = await counter.locator('.increment');
+ await increment.click();
+
+ await expect(count, 'count incremented by 1').toHaveText('1');
+ });
+
+ test('Svelte counter', async ({ astro, page }) => {
+ await page.goto(astro.resolveUrl('/'));
+
+ const counter = await page.locator('#svelte-counter');
+ await expect(counter, 'component is visible').toBeVisible();
+
+ const fallback = await page.locator('[data-fallback=svelte]');
+ await expect(fallback, 'fallback content is hidden').not.toBeVisible();
+
+ const count = await counter.locator('pre');
+ await expect(count, 'initial count is 0').toHaveText('0');
+
+ const children = await counter.locator('.children');
+ await expect(children, 'children exist').toHaveText('svelte');
+
+ const increment = await counter.locator('.increment');
+ await increment.click();
+
+ await expect(count, 'count incremented by 1').toHaveText('1');
+ });
+});
diff --git a/packages/astro/e2e/csp-view-transitions.test.js b/packages/astro/e2e/csp-view-transitions.test.js
new file mode 100644
index 000000000000..c7f3ac9aa0f5
--- /dev/null
+++ b/packages/astro/e2e/csp-view-transitions.test.js
@@ -0,0 +1,1626 @@
+import { expect } from '@playwright/test';
+import { testFactory } from './test-utils.js';
+
+const test = testFactory(import.meta.url, {
+ root: './fixtures/view-transitions/',
+ experimental: {
+ csp: true,
+ },
+});
+
+let previewServer;
+
+test.beforeAll(async ({ astro }) => {
+ await astro.build();
+ previewServer = await astro.preview();
+});
+
+test.afterAll(async () => {
+ await previewServer.stop();
+});
+function collectLoads(page) {
+ const loads = [];
+ page.on('load', async () => {
+ const url = page.url();
+ if (url !== 'about:blank') loads.push(await page.title());
+ });
+ return loads;
+}
+function scrollToBottom(page) {
+ return page.evaluate(() => {
+ window.scrollY = document.documentElement.scrollHeight;
+ window.dispatchEvent(new Event('scroll'));
+ });
+}
+
+function collectPreloads(page) {
+ return page.evaluate(() => {
+ window.preloads = [];
+ const observer = new MutationObserver((mutations) => {
+ mutations.forEach((mutation) =>
+ mutation.addedNodes.forEach((node) => {
+ if (node.nodeName === 'LINK' && node.rel === 'preload') preloads.push(node.href);
+ }),
+ );
+ });
+ observer.observe(document.head, { childList: true });
+ });
+}
+
+async function nativeViewTransition(page) {
+ return page.evaluate(() => document.startViewTransition !== undefined);
+}
+
+test.describe('CSP View Transitions', () => {
+ test('Moving from page 1 to page 2', async ({ page, astro }) => {
+ const loads = collectLoads(page);
+
+ // Go to page 1
+ await page.goto(astro.resolveUrl('/one'));
+ let p = page.locator('#one');
+ await expect(p, 'should have content').toHaveText('Page 1');
+
+ // go to page 2
+ await page.click('#click-two');
+ p = page.locator('#two');
+ await expect(p, 'should have content').toHaveText('Page 2');
+
+ expect(loads.length, 'There should only be 1 page load').toEqual(1);
+ });
+
+ test('Back button is captured', async ({ page, astro }) => {
+ const loads = collectLoads(page);
+
+ // Go to page 1
+ await page.goto(astro.resolveUrl('/one'));
+ let p = page.locator('#one');
+ await expect(p, 'should have content').toHaveText('Page 1');
+
+ // go to page 2
+ await page.click('#click-two');
+ p = page.locator('#two');
+ await expect(p, 'should have content').toHaveText('Page 2');
+
+ // Back to page 1
+ await page.goBack();
+ p = page.locator('#one');
+ await expect(p, 'should have content').toHaveText('Page 1');
+
+ expect(loads.length, 'There should only be 1 page load').toEqual(1);
+ });
+
+ test('Clicking on a link with nested content', async ({ page, astro }) => {
+ const loads = collectLoads(page);
+ // Go to page 4
+ await page.goto(astro.resolveUrl('/four'));
+ let p = page.locator('#four');
+ await expect(p, 'should have content').toHaveText('Page 4');
+
+ // Go to page 1
+ await page.click('#click-one');
+ p = page.locator('#one');
+ await expect(p, 'should have content').toHaveText('Page 1');
+
+ expect(loads.length, 'There should only be 1 page load').toEqual(1);
+ });
+
+ test('Clicking on a link to a page with non-recommended headers', async ({ page, astro }) => {
+ const loads = collectLoads(page);
+ // Go to page 4
+ await page.goto(astro.resolveUrl('/one'));
+ let p = page.locator('#one');
+ await expect(p, 'should have content').toHaveText('Page 1');
+
+ // Go to page 1
+ await page.click('#click-seven');
+ p = page.locator('#seven');
+ await expect(p, 'should have content').toHaveText('Page 7');
+
+ expect(loads.length, 'There should only be 1 page load').toEqual(1);
+ });
+
+ test('Moving to a page without ClientRouter triggers a full page navigation', async ({
+ page,
+ astro,
+ }) => {
+ const loads = collectLoads(page);
+
+ // Go to page 1
+ await page.goto(astro.resolveUrl('/one'));
+ let p = page.locator('#one');
+ await expect(p, 'should have content').toHaveText('Page 1');
+
+ // Go to page 3 which does *not* have ClientRouter enabled
+ await page.click('#click-three');
+ p = page.locator('#three');
+ await expect(p, 'should have content').toHaveText('Page 3');
+
+ expect(
+ loads.length,
+ 'There should be 2 page loads. The original, then going from 3 to 2',
+ ).toEqual(2);
+ });
+
+ test('Moving within a page without ClientRouter does not trigger a full page navigation', async ({
+ page,
+ astro,
+ }) => {
+ const loads = collectLoads(page);
+ // Go to page 1
+ await page.goto(astro.resolveUrl('/one'));
+ let p = page.locator('#one');
+ await expect(p, 'should have content').toHaveText('Page 1');
+
+ // Go to page 3 which does *not* have ClientRouter enabled
+ await page.click('#click-three');
+ p = page.locator('#three');
+ await expect(p, 'should have content').toHaveText('Page 3');
+
+ // click a hash link to navigate further down the page
+ await page.click('#click-hash');
+ // still on page 3
+ p = page.locator('#three');
+ await expect(p, 'should have content').toHaveText('Page 3');
+
+ expect(
+ loads.length,
+ 'There should be only 2 page loads (for page one & three), but no additional loads for the hash change',
+ ).toEqual(2);
+ });
+
+ test('Moving from a page without ClientRouter w/ back button', async ({ page, astro }) => {
+ const loads = collectLoads(page);
+ // Go to page 1
+ await page.goto(astro.resolveUrl('/one'));
+ let p = page.locator('#one');
+ await expect(p, 'should have content').toHaveText('Page 1');
+
+ // Go to page 3 which does *not* have ClientRouter enabled
+ await page.click('#click-three');
+ p = page.locator('#three');
+ await expect(p, 'should have content').toHaveText('Page 3');
+
+ // Back to page 1
+ await page.goBack();
+ p = page.locator('#one');
+ await expect(p, 'should have content').toHaveText('Page 1');
+ expect(
+ loads.length,
+ 'There should be 3 page loads (for page one & three), and an additional loads for the back navigation',
+ ).toEqual(3);
+ });
+
+ test('Stylesheets in the head are waited on', async ({ page, astro }) => {
+ // Go to page 1
+ await page.goto(astro.resolveUrl('/one'));
+ let p = page.locator('#one');
+ await expect(p, 'should have content').toHaveText('Page 1');
+
+ await collectPreloads(page);
+
+ // Go to page 2
+ await page.click('#click-two');
+ p = page.locator('#two');
+ await expect(p, 'should have content').toHaveText('Page 2');
+ await expect(p, 'imported CSS updated').toHaveCSS('font-size', '24px');
+ const preloads = await page.evaluate(() => window.preloads);
+ expect(preloads.length === 1 && preloads[0].endsWith('/two.css')).toBeTruthy();
+ });
+
+ test('astro:page-load event fires when navigating to new page', async ({ page, astro }) => {
+ // Go to page 1
+ await page.goto(astro.resolveUrl('/one'));
+ const p = page.locator('#one');
+ await expect(p, 'should have content').toHaveText('Page 1');
+
+ // go to page 2
+ await page.click('#click-two');
+ const article = page.locator('#twoarticle');
+ await expect(article, 'should have script content').toHaveText('works');
+ });
+
+ test('astro:page-load event fires when navigating directly to a page', async ({
+ page,
+ astro,
+ }) => {
+ // Go to page 2
+ await page.goto(astro.resolveUrl('/two'));
+ const article = page.locator('#twoarticle');
+ await expect(article, 'should have script content').toHaveText('works');
+ });
+
+ test('astro:after-swap event fires right after the swap', async ({ page, astro }) => {
+ // Go to page 1
+ await page.goto(astro.resolveUrl('/one'));
+ let p = page.locator('#one');
+ await expect(p, 'should have content').toHaveText('Page 1');
+
+ // go to page 2
+ await page.click('#click-two');
+ p = page.locator('#two');
+ const h = page.locator('html');
+ await expect(h, 'imported CSS updated').toHaveCSS('background-color', 'rgba(0, 0, 0, 0)');
+ });
+
+ test('No page rendering during swap()', async ({ page, astro }) => {
+ // This has been a problem with theme switchers (e.g. for darkmode)
+ // Swap() should not trigger any page renders and give users the chance to
+ // correct attributes in the astro:after-swap handler before they become visible
+
+ // This test uses a CSS animation to detect page rendering
+ // The test succeeds if no additional animation beside those of the
+ // view transition is triggered during swap()
+
+ // Only works for browsers with native view transitions
+ if (!(await nativeViewTransition(page))) return;
+
+ await page.goto(astro.resolveUrl('/listener-one'));
+ let p = page.locator('#totwo');
+ await expect(p, 'should have content').toHaveText('Go to listener two');
+
+ // setting the blue class on the html element triggers a CSS animation
+ let animations = await page.evaluate(async () => {
+ document.documentElement.classList.add('blue');
+ return document.getAnimations();
+ });
+ expect(animations.length).toEqual(1);
+
+ // go to page 2
+ await page.click('#totwo');
+ p = page.locator('#toone');
+ await expect(p, 'should have content').toHaveText('Go to listener one');
+ // swap() resets the "blue" class, as it is not set in the static html of page 2
+ // The astro:after-swap listener (defined in the layout) sets it to "blue" again.
+ // The temporarily missing class must not trigger page rendering.
+
+ // When the after-swap listener starts, no animations should be running
+ // after-swap listener sets animations to document.getAnimations().length
+ // and we expect this to be zero
+ await expect(page.locator('html')).toHaveAttribute('animations', '0');
+ });
+
+ test('click hash links does not do navigation', async ({ page, astro }) => {
+ // Go to page 1
+ await page.goto(astro.resolveUrl('/one'));
+ const p = page.locator('#one');
+ await expect(p, 'should have content').toHaveText('Page 1');
+
+ // Clicking 1 stays put
+ await page.click('#click-one');
+ await expect(p, 'should have content').toHaveText('Page 1');
+ });
+
+ test('click self link (w/o hash) does not do navigation', async ({ page, astro }) => {
+ const loads = collectLoads(page);
+
+ // Go to page 1
+ await page.goto(astro.resolveUrl('/one'));
+ const p = page.locator('#one');
+ await expect(p, 'should have content').toHaveText('Page 1');
+
+ // Clicking href="" stays on page
+ await page.click('#click-self');
+ await expect(p, 'should have content').toHaveText('Page 1');
+ expect(loads.length, 'There should only be 1 page load').toEqual(1);
+ });
+
+ test('Scroll position restored on back button', async ({ page, astro }) => {
+ // Go to page 1
+ await page.goto(astro.resolveUrl('/long-page'));
+ let article = page.locator('#longpage');
+ await expect(article, 'should have script content').toBeVisible('exists');
+
+ await scrollToBottom(page);
+ const oldScrollY = await page.evaluate(() => window.scrollY);
+
+ // go to page long-page
+ await page.click('#click-one');
+ let p = page.locator('#one');
+ await expect(p, 'should have content').toHaveText('Page 1');
+
+ // Back to page 1
+ await page.goBack();
+
+ const newScrollY = await page.evaluate(() => window.scrollY);
+ expect(oldScrollY).toEqual(newScrollY);
+ });
+
+ test('Fragment scroll position restored on back button', async ({ page, astro }) => {
+ // Go to the long page
+ await page.goto(astro.resolveUrl('/long-page'));
+ let locator = page.locator('#longpage');
+ await expect(locator).toBeInViewport();
+
+ // Scroll down to middle fragment
+ await page.click('#click-scroll-down');
+ locator = page.locator('#click-one-again');
+ await expect(locator).toBeInViewport();
+
+ // Scroll up to top fragment
+ await page.click('#click-scroll-up');
+ locator = page.locator('#longpage');
+ await expect(locator).toBeInViewport();
+
+ // Back to middle of the page
+ await page.goBack();
+ locator = page.locator('#click-one-again');
+ await expect(locator).toBeInViewport();
+ });
+
+ test('Scroll position restored when transitioning back to fragment', async ({ page, astro }) => {
+ // Go to the long page
+ await page.goto(astro.resolveUrl('/long-page'));
+ let locator = page.locator('#longpage');
+ await expect(locator).toBeInViewport();
+
+ // Scroll down to middle fragment
+ await page.click('#click-scroll-down');
+ locator = page.locator('#click-one-again');
+ await expect(locator).toBeInViewport();
+
+ // goto page 1
+ await page.click('#click-one-again');
+ locator = page.locator('#one');
+ await expect(locator).toHaveText('Page 1');
+
+ // Back to middle of the previous page
+ await page.goBack();
+ locator = page.locator('#click-one-again');
+ await expect(locator).toBeInViewport();
+ });
+
+ test('Scroll position restored on forward button', async ({ page, astro }) => {
+ // Go to page 1
+ await page.goto(astro.resolveUrl('/one'));
+ let p = page.locator('#one');
+ await expect(p, 'should have content').toHaveText('Page 1');
+
+ // go to page long-page
+ await page.click('#click-longpage');
+ let article = page.locator('#longpage');
+ await expect(article, 'should have script content').toBeVisible('exists');
+
+ await scrollToBottom(page);
+ const oldScrollY = await page.evaluate(() => window.scrollY);
+
+ // Back to page 1
+ await page.goBack();
+
+ // Go forward
+ await page.goForward();
+ article = page.locator('#longpage');
+ await expect(article, 'should have script content').toBeVisible('exists');
+
+ const newScrollY = await page.evaluate(() => window.scrollY);
+ expect(oldScrollY).toEqual(newScrollY);
+ });
+
+ test('Fragment scroll position restored on forward button', async ({ page, astro }) => {
+ // Go to the long page
+ await page.goto(astro.resolveUrl('/long-page'));
+ let locator = page.locator('#longpage');
+ await expect(locator).toBeInViewport();
+
+ // Scroll down to middle fragment
+ await page.click('#click-scroll-down');
+ locator = page.locator('#click-one-again');
+ await expect(locator).toBeInViewport();
+
+ // Scroll back to top
+ await page.goBack();
+ locator = page.locator('#longpage');
+ await expect(locator).toBeInViewport();
+
+ // Forward to middle of page
+ await page.goForward();
+ locator = page.locator('#click-one-again');
+ await expect(locator).toBeInViewport();
+ });
+
+ // We don't support inline scripts yet
+ test.skip('View Transitions Rule', async ({ page, astro }) => {
+ let consoleCount = 0;
+ page.on('console', (msg) => {
+ // This count is used for transition events
+ if (msg.text() === 'ready') consoleCount++;
+ });
+ // Don't test back and forward '' to '', because They are not stored in the history.
+ // click '' to '' (transition)
+ await page.goto(astro.resolveUrl('/long-page'));
+ let locator = page.locator('#longpage');
+ await expect(locator).toBeInViewport();
+ let consolePromise = page.waitForEvent('console');
+ await page.click('#click-self');
+ await consolePromise;
+ locator = page.locator('#longpage');
+ await expect(locator).toBeInViewport();
+ expect(consoleCount).toEqual(1);
+
+ // click '' to 'hash' (no transition)
+ await page.click('#click-scroll-down');
+ locator = page.locator('#click-one-again');
+ await expect(locator).toBeInViewport();
+ expect(consoleCount).toEqual(1);
+
+ // back 'hash' to '' (no transition)
+ await page.goBack();
+ locator = page.locator('#longpage');
+ await expect(locator).toBeInViewport();
+ expect(consoleCount).toEqual(1);
+
+ // forward '' to 'hash' (no transition)
+ // NOTE: the networkidle below is needed for Firefox to consistently
+ // pass the `#longpage` viewport check below
+ await page.goForward({ waitUntil: 'networkidle' });
+ locator = page.locator('#click-one-again');
+ await expect(locator).toBeInViewport();
+ expect(consoleCount).toEqual(1);
+
+ // click 'hash' to 'hash' (no transition)
+ await page.click('#click-scroll-up');
+ locator = page.locator('#longpage');
+ await expect(locator).toBeInViewport();
+ expect(consoleCount).toEqual(1);
+
+ // back 'hash' to 'hash' (no transition)
+ await page.goBack();
+ locator = page.locator('#click-one-again');
+ await expect(locator).toBeInViewport();
+ expect(consoleCount).toEqual(1);
+
+ // forward 'hash' to 'hash' (no transition)
+ await page.goForward();
+ locator = page.locator('#longpage');
+ await expect(locator).toBeInViewport();
+ expect(consoleCount).toEqual(1);
+
+ // click 'hash' to '' (transition)
+ consolePromise = page.waitForEvent('console');
+ await page.click('#click-self');
+ await consolePromise;
+ locator = page.locator('#longpage');
+ await expect(locator).toBeInViewport();
+ expect(consoleCount).toEqual(2);
+
+ // back '' to 'hash' (transition)
+ consolePromise = page.waitForEvent('console');
+ await page.goBack();
+ await consolePromise;
+ locator = page.locator('#longpage');
+ await expect(locator).toBeInViewport();
+ expect(consoleCount).toEqual(3);
+
+ // forward 'hash' to '' (transition)
+ consolePromise = page.waitForEvent('console');
+ await page.goForward();
+ await consolePromise;
+ locator = page.locator('#longpage');
+ await expect(locator).toBeInViewport();
+ expect(consoleCount).toEqual(4);
+ });
+
+ test(' component forwards transitions to the
', async ({ page, astro }) => {
+ // Go to page 1
+ await page.goto(astro.resolveUrl('/image-one'));
+ const img = page.locator('img[data-astro-transition-scope]');
+ await expect(img).toBeVisible('The image tag should have the transition scope attribute.');
+ });
+
+ test('