diff --git a/.changeset/crazy-doors-buy.md b/.changeset/crazy-doors-buy.md
new file mode 100644
index 000000000000..0e6c66edea30
--- /dev/null
+++ b/.changeset/crazy-doors-buy.md
@@ -0,0 +1,28 @@
+---
+'astro': minor
+---
+
+Adds experimental Content Security Policy (CSP) support
+
+CSP is an important feature to provide fine-grained control over resources that can or cannot be downloaded and executed by a document. In particular, it can help protect against [cross-site scripting (XSS)](https://developer.mozilla.org/en-US/docs/Glossary/Cross-site_scripting) attacks.
+
+Enabling this feature adds additional security to Astro's handling of processed and bundled scripts and styles by default, and allows you to further configure these, and additional, content types. This new experimental feature has been designed to work in every Astro rendering environment (static pages, dynamic pages and single page applications), while giving you maximum flexibility and with type-safety in mind.
+
+It is compatible with most of Astro's features such as client islands, and server islands, although Astro's view transitions using the `` are not yet fully supported. Inline scripts are not supported out of the box, but you can provide your own hashes for external and inline scripts.
+
+To enable this feature, add the experimental flag in your Astro config:
+
+```js
+// astro.config.mjs
+import { defineConfig } from "astro/config"
+
+export default defineConfig({
+ experimental: {
+ csp: true
+ }
+})
+```
+
+For more information on enabling and using this feature in your project, see the [Experimental CSP docs](https://docs.astro.build/en/reference/experimental-flags/csp/).
+
+For a complete overview, and to give feedback on this experimental API, see the [Content Security Policy RFC](https://github.com/withastro/roadmap/blob/feat/rfc-csp/proposals/0055-csp.md).
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-server-islands.test.js b/packages/astro/e2e/csp-server-islands.test.js
new file mode 100644
index 000000000000..fb7359e0a341
--- /dev/null
+++ b/packages/astro/e2e/csp-server-islands.test.js
@@ -0,0 +1,39 @@
+import { expect } from '@playwright/test';
+import { testFactory } from './test-utils.js';
+
+const test = testFactory(import.meta.url, {
+ root: './fixtures/csp-server-islands/',
+});
+
+test.describe('CSP Server islands', () => {
+ test.describe('Production', () => {
+ let previewServer;
+
+ test.beforeAll(async ({ astro }) => {
+ // Playwright's Node version doesn't have these functions, so stub them.
+ process.stdout.clearLine = () => {};
+ process.stdout.cursorTo = () => {};
+ await astro.build();
+ previewServer = await astro.preview();
+ });
+
+ test.afterAll(async () => {
+ await previewServer.stop();
+ });
+
+ test('Only one component in prod', async ({ page, astro }) => {
+ await page.goto(astro.resolveUrl('/base/'));
+
+ let el = page.locator('#basics .island');
+
+ await expect(el, 'element rendered').toBeVisible();
+ await expect(el, 'should have content').toHaveText('I am an island');
+ });
+
+ test('Props are encrypted', async ({ page, astro }) => {
+ await page.goto(astro.resolveUrl('/'));
+ let el = page.locator('#basics .secret');
+ await expect(el).toHaveText('test');
+ });
+ });
+});
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('