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('