diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fe2959a354c5..a246ce645389 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -38,9 +38,44 @@ jobs: strategy: fail-fast: false matrix: - node-version: [16] - os: [ubuntu-latest, windows-latest] - e2e-browser: ['chromium'] + include: + - node-version: 16 + os: [ubuntu-latest, windows-latest] + e2e-browser: 'chromium' + - node-version: 18 + os: ubuntu-latest + e2e-browser: 'chromium' + env: + TURBO_CACHE_KEY: ${{ matrix.os }}-${{ matrix.node-version }} + KIT_E2E_BROWSER: ${{matrix.e2e-browser}} + steps: + - run: git config --global core.autocrlf false + - uses: actions/checkout@v3 + - uses: pnpm/action-setup@v2.2.4 + - uses: actions/setup-node@v3 + with: + node-version: ${{ matrix.node-version }} + cache: pnpm + - run: pnpm install --frozen-lockfile + - run: pnpm playwright install ${{ matrix.e2e-browser }} + - run: pnpm test + - name: Archive test results + if: failure() + shell: bash + run: find packages -type d -name test-results -not -empty | tar -czf test-results.tar.gz --files-from=- + - name: Upload test results + if: failure() + uses: actions/upload-artifact@v3 + with: + retention-days: 3 + name: test-failure-${{ github.run_id }}-${{ matrix.os }}-${{ matrix.node-version }}-${{ matrix.e2e-browser }} + path: test-results.tar.gz + Cross-browser-test: + runs-on: ${{ matrix.os }} + timeout-minutes: 30 + strategy: + fail-fast: false + matrix: include: - node-version: 16 os: ubuntu-latest @@ -48,9 +83,6 @@ jobs: - node-version: 16 os: macOS-latest e2e-browser: 'webkit' - - node-version: 18 - os: ubuntu-latest - e2e-browser: 'chromium' env: TURBO_CACHE_KEY: ${{ matrix.os }}-${{ matrix.node-version }} KIT_E2E_BROWSER: ${{matrix.e2e-browser}} @@ -64,7 +96,7 @@ jobs: cache: pnpm - run: pnpm install --frozen-lockfile - run: pnpm playwright install ${{ matrix.e2e-browser }} - - run: pnpm test + - run: pnpm test:cross-browser - name: Archive test results if: failure() shell: bash diff --git a/package.json b/package.json index 70b9771dcc95..e50d3a5cffe3 100644 --- a/package.json +++ b/package.json @@ -5,6 +5,7 @@ "private": true, "scripts": { "test": "turbo run test --filter=./packages/*", + "test:cross-browser": "turbo run test:cross-browser --filter=./packages/*", "test:vite-ecosystem-ci": "pnpm test --dir packages/kit", "check": "turbo run check", "lint": "turbo run lint", diff --git a/packages/kit/package.json b/packages/kit/package.json index a3ca83698a58..31078be85a12 100644 --- a/packages/kit/package.json +++ b/packages/kit/package.json @@ -63,6 +63,7 @@ "format": "pnpm lint --write", "test": "pnpm test:unit && pnpm test:integration", "test:integration": "pnpm -r --workspace-concurrency 1 --filter=\"./test/**\" test", + "test:cross-browser": "pnpm -r --workspace-concurrency 1 --filter=\"./test/**\" test:cross-browser", "test:unit": "uvu src \"(spec\\.js|test[\\\\/]index\\.js)\"", "postinstall": "node postinstall.js" }, diff --git a/packages/kit/test/apps/basics/package.json b/packages/kit/test/apps/basics/package.json index 6257ff4b4556..0fe2c3750a4a 100644 --- a/packages/kit/test/apps/basics/package.json +++ b/packages/kit/test/apps/basics/package.json @@ -9,7 +9,10 @@ "check": "svelte-kit sync && tsc && svelte-check", "test": "node test/setup.js && pnpm test:dev && pnpm test:build", "test:dev": "rimraf test/errors.json && cross-env DEV=true playwright test", - "test:build": "rimraf test/errors.json && playwright test" + "test:build": "rimraf test/errors.json && playwright test", + "test:cross-browser": "npm run test:cross-browser:dev && npm run test:cross-browser:build", + "test:cross-browser:dev": "node test/setup.js && rimraf test/errors.json && cross-env DEV=true playwright test test/cross-browser/", + "test:cross-browser:build": "node test/setup.js && rimraf test/errors.json && playwright test test/cross-browser/" }, "devDependencies": { "@sveltejs/kit": "workspace:^", diff --git a/packages/kit/test/apps/basics/test/client.test.js b/packages/kit/test/apps/basics/test/client.test.js index f4fa88f8008a..4ff03270be9b 100644 --- a/packages/kit/test/apps/basics/test/client.test.js +++ b/packages/kit/test/apps/basics/test/client.test.js @@ -8,95 +8,13 @@ test.skip(({ javaScriptEnabled }) => !javaScriptEnabled); test.describe.configure({ mode: 'parallel' }); test.describe('a11y', () => { - test('resets focus', async ({ page, clicknav, browserName }) => { - const tab = browserName === 'webkit' ? 'Alt+Tab' : 'Tab'; + test('applies autofocus after an enhanced form submit', async ({ page }) => { + await page.goto('/accessibility/autofocus/b'); - await page.goto('/accessibility/a'); - - await clicknav('[href="/accessibility/b"]'); - expect(await page.innerHTML('h1')).toBe('b'); - expect(await page.evaluate(() => (document.activeElement || {}).nodeName)).toBe('BODY'); - await page.keyboard.press(tab); - - expect(await page.evaluate(() => (document.activeElement || {}).nodeName)).toBe('BUTTON'); - expect(await page.evaluate(() => (document.activeElement || {}).textContent)).toBe('focus me'); - - await clicknav('[href="/accessibility/a"]'); - expect(await page.innerHTML('h1')).toBe('a'); - expect(await page.evaluate(() => (document.activeElement || {}).nodeName)).toBe('BODY'); - - await page.keyboard.press(tab); - expect(await page.evaluate(() => (document.activeElement || {}).nodeName)).toBe('BUTTON'); - expect(await page.evaluate(() => (document.activeElement || {}).textContent)).toBe('focus me'); - - expect(await page.evaluate(() => document.documentElement.getAttribute('tabindex'))).toBe(null); - }); - - test('applies autofocus after a navigation', async ({ page, clicknav }) => { - await page.goto('/accessibility/autofocus/a'); - - await clicknav('[href="/accessibility/autofocus/b"]'); - expect(await page.innerHTML('h1')).toBe('b'); - expect(await page.evaluate(() => (document.activeElement || {}).nodeName)).toBe('INPUT'); - }); - - (process.env.KIT_E2E_BROWSER === 'webkit' ? test.skip : test)( - 'applies autofocus after an enhanced form submit', - async ({ page }) => { - await page.goto('/accessibility/autofocus/b'); - - await page.click('#submit'); - await page.waitForFunction(() => document.activeElement?.nodeName === 'INPUT', null, { - timeout: 1000 - }); - } - ); - - test('announces client-side navigation', async ({ page, clicknav, javaScriptEnabled }) => { - await page.goto('/accessibility/a'); - - const has_live_region = (await page.innerHTML('body')).includes('aria-live'); - - if (javaScriptEnabled) { - expect(has_live_region).toBeTruthy(); - - // live region should exist, but be empty - expect(await page.innerHTML('[aria-live]')).toBe(''); - - await clicknav('[href="/accessibility/b"]'); - expect(await page.innerHTML('[aria-live]')).toBe('b'); // TODO i18n - } else { - expect(has_live_region).toBeFalsy(); - } - }); - - test('reset selection', async ({ page, clicknav }) => { - await page.goto('/selection/a'); - - expect( - await page.evaluate(() => { - const range = document.createRange(); - range.selectNodeContents(document.body); - const selection = getSelection(); - if (selection) { - selection.removeAllRanges(); - selection.addRange(range); - return selection.rangeCount; - } - return -1; - }) - ).toBe(1); - - await clicknav('[href="/selection/b"]'); - expect( - await page.evaluate(() => { - const selection = getSelection(); - if (selection) { - return selection.rangeCount; - } - return -1; - }) - ).toBe(0); + await page.click('#submit'); + await page.waitForFunction(() => document.activeElement?.nodeName === 'INPUT', null, { + timeout: 1000 + }); }); }); @@ -113,294 +31,6 @@ test.describe('Caching', () => { }); }); -test.describe('beforeNavigate', () => { - test('prevents navigation triggered by link click', async ({ page, baseURL }) => { - await page.goto('/before-navigate/prevent-navigation'); - - await page.click('[href="/before-navigate/a"]'); - await page.waitForLoadState('networkidle'); - - expect(page.url()).toBe(baseURL + '/before-navigate/prevent-navigation'); - expect(await page.innerHTML('pre')).toBe('1 false link'); - }); - - test('prevents navigation to external', async ({ page, baseURL }) => { - await page.goto('/before-navigate/prevent-navigation'); - await page.click('h1'); // The browsers block attempts to prevent navigation on a frame that's never had a user gesture. - - page.on('dialog', (dialog) => dialog.dismiss()); - - page.click('a[href="https://google.de"]'); // do NOT await this, promise only resolves after successful navigation, which never happens - await page.waitForTimeout(500); - await expect(page.locator('pre')).toHaveText('1 true link'); - expect(page.url()).toBe(baseURL + '/before-navigate/prevent-navigation'); - }); - - test('prevents navigation triggered by goto', async ({ page, app, baseURL }) => { - await page.goto('/before-navigate/prevent-navigation'); - await app.goto('/before-navigate/a'); - expect(page.url()).toBe(baseURL + '/before-navigate/prevent-navigation'); - expect(await page.innerHTML('pre')).toBe('1 false goto'); - }); - - test('prevents external navigation triggered by goto', async ({ page, app, baseURL }) => { - await page.goto('/before-navigate/prevent-navigation'); - await app.goto('https://google.de'); - expect(page.url()).toBe(baseURL + '/before-navigate/prevent-navigation'); - expect(await page.innerHTML('pre')).toBe('1 true goto'); - }); - - test('prevents navigation triggered by back button', async ({ page, app, baseURL }) => { - await page.goto('/before-navigate/a'); - await app.goto('/before-navigate/prevent-navigation'); - await page.click('h1'); // The browsers block attempts to prevent navigation on a frame that's never had a user gesture. - - await page.goBack(); - expect(await page.innerHTML('pre')).toBe('1 false popstate'); - expect(page.url()).toBe(baseURL + '/before-navigate/prevent-navigation'); - }); - - test('prevents unload', async ({ page }) => { - await page.goto('/before-navigate/prevent-navigation'); - await page.click('h1'); // The browsers block attempts to prevent navigation on a frame that's never had a user gesture. - const type = new Promise((fulfil) => { - page.on('dialog', async (dialog) => { - fulfil(dialog.type()); - await dialog.dismiss(); - }); - }); - - await page.close({ runBeforeUnload: true }); - expect(await type).toBe('beforeunload'); - expect(await page.innerHTML('pre')).toBe('1 true leave'); - }); - - test('is not triggered on redirect', async ({ page, baseURL }) => { - await page.goto('/before-navigate/prevent-navigation'); - - await page.click('[href="/before-navigate/redirect"]'); - await page.waitForLoadState('networkidle'); - - expect(page.url()).toBe(baseURL + '/before-navigate/prevent-navigation'); - expect(await page.innerHTML('pre')).toBe('1 false link'); - }); - - test('is not triggered on target=_blank', async ({ page, baseURL }) => { - await page.goto('/before-navigate/prevent-navigation'); - - await page.click('a[href="https://google.com"]'); - await page.waitForTimeout(500); - - expect(page.url()).toBe(baseURL + '/before-navigate/prevent-navigation'); - expect(await page.innerHTML('pre')).toBe('0 false undefined'); - }); -}); - -test.describe('Scrolling', () => { - test('url-supplied anchor works on direct page load', async ({ page, in_view }) => { - await page.goto('/anchor/anchor#go-to-element'); - expect(await in_view('#go-to-element')).toBe(true); - }); - - test('url-supplied anchor works on navigation to page', async ({ page, in_view, clicknav }) => { - await page.goto('/anchor'); - await clicknav('#first-anchor'); - expect(await in_view('#go-to-element')).toBe(true); - }); - - test('url-supplied anchor works when navigated from scrolled page', async ({ - page, - clicknav, - in_view - }) => { - await page.goto('/anchor'); - await clicknav('#second-anchor'); - expect(await in_view('#go-to-element')).toBe(true); - }); - - test('no-anchor url will scroll to top when navigated from scrolled page', async ({ - page, - clicknav - }) => { - await page.goto('/anchor'); - await clicknav('#third-anchor'); - expect(await page.evaluate(() => scrollY === 0)).toBeTruthy(); - }); - - test('url-supplied anchor works when navigated from bottom of page', async ({ - page, - clicknav, - in_view - }) => { - await page.goto('/anchor'); - await clicknav('#last-anchor'); - expect(await in_view('#go-to-element')).toBe(true); - }); - - test('no-anchor url will scroll to top when navigated from bottom of page', async ({ - clicknav, - page - }) => { - await page.goto('/anchor'); - await clicknav('#last-anchor-2'); - expect(await page.evaluate(() => scrollY === 0)).toBeTruthy(); - }); - - test('scroll is restored after hitting the back button', async ({ baseURL, clicknav, page }) => { - await page.goto('/anchor'); - await page.locator('#scroll-anchor').click(); - const originalScrollY = /** @type {number} */ (await page.evaluate(() => scrollY)); - await clicknav('#routing-page'); - await page.goBack(); - await page.waitForLoadState('networkidle'); - expect(page.url()).toBe(baseURL + '/anchor#last-anchor-2'); - expect(await page.evaluate(() => scrollY)).toEqual(originalScrollY); - - await page.goBack(); - await page.waitForLoadState('networkidle'); - - expect(page.url()).toBe(baseURL + '/anchor'); - expect(await page.evaluate(() => scrollY)).toEqual(0); - }); - - test('scroll is restored after hitting the back button for an in-app cross-document navigation', async ({ - page, - clicknav - }) => { - await page.goto('/scroll/cross-document/a'); - - const rect = await page.locator('[href="/scroll/cross-document/b"]').boundingBox(); - const height = await page.evaluate(() => innerHeight); - if (!rect) throw new Error('Could not determine bounding box'); - - const target_scroll_y = rect.y + rect.height - height; - await page.evaluate((y) => scrollTo(0, y), target_scroll_y); - - await page.locator('[href="/scroll/cross-document/b"]').click(); - expect(await page.textContent('h1')).toBe('b'); - await page.waitForSelector('body.started'); - - await clicknav('[href="/scroll/cross-document/c"]'); - expect(await page.textContent('h1')).toBe('c'); - - await page.goBack(); // client-side back - await page.goBack(); // native back - expect(await page.textContent('h1')).toBe('a'); - await page.waitForSelector('body.started'); - - await page.waitForTimeout(250); // needed for the test to fail reliably without the fix - - const scroll_y = await page.evaluate(() => scrollY); - - expect(Math.abs(scroll_y - target_scroll_y)).toBeLessThan(50); // we need a few pixels wiggle room, because browsers - }); - - test('url-supplied anchor is ignored with onMount() scrolling on direct page load', async ({ - page, - in_view - }) => { - await page.goto('/anchor-with-manual-scroll/anchor-onmount#go-to-element'); - expect(await in_view('#abcde')).toBe(true); - }); - - test('url-supplied anchor is ignored with afterNavigate() scrolling on direct page load', async ({ - page, - in_view, - clicknav - }) => { - await page.goto('/anchor-with-manual-scroll/anchor-afternavigate#go-to-element'); - expect(await in_view('#abcde')).toBe(true); - - await clicknav('[href="/anchor-with-manual-scroll/anchor-afternavigate?x=y#go-to-element"]'); - expect(await in_view('#abcde')).toBe(true); - }); - - test('url-supplied anchor is ignored with onMount() scrolling on navigation to page', async ({ - page, - clicknav, - javaScriptEnabled, - in_view - }) => { - await page.goto('/anchor-with-manual-scroll'); - await clicknav('[href="/anchor-with-manual-scroll/anchor-onmount#go-to-element"]'); - if (javaScriptEnabled) expect(await in_view('#abcde')).toBe(true); - else expect(await in_view('#go-to-element')).toBe(true); - }); - - test('app-supplied scroll and focus work on direct page load', async ({ page, in_view }) => { - await page.goto('/use-action/focus-and-scroll'); - expect(await in_view('#input')).toBe(true); - await expect(page.locator('#input')).toBeFocused(); - }); - - test('app-supplied scroll and focus work on navigation to page', async ({ - page, - clicknav, - in_view - }) => { - await page.goto('/use-action'); - await clicknav('[href="/use-action/focus-and-scroll"]'); - expect(await in_view('#input')).toBe(true); - await expect(page.locator('input')).toBeFocused(); - }); - - test('scroll positions are recovered on reloading the page', async ({ page, app }) => { - await page.goto('/anchor'); - await page.evaluate(() => window.scrollTo(0, 1000)); - await app.goto('/anchor/anchor'); - await page.evaluate(() => window.scrollTo(0, 1000)); - - await page.reload(); - expect(await page.evaluate(() => window.scrollY)).toBe(1000); - - await page.goBack(); - expect(await page.evaluate(() => window.scrollY)).toBe(1000); - }); - - test('scroll position is top of page on ssr:false reload', async ({ page }) => { - await page.goto('/no-ssr/margin'); - expect(await page.evaluate(() => window.scrollY)).toBe(0); - await page.reload(); - expect(await page.evaluate(() => window.scrollY)).toBe(0); - }); -}); - -test.describe('afterNavigate', () => { - test('calls callback', async ({ page, clicknav }) => { - await page.goto('/after-navigate/a'); - expect(await page.textContent('h1')).toBe('undefined -> /after-navigate/a'); - - await clicknav('[href="/after-navigate/b"]'); - expect(await page.textContent('h1')).toBe('/after-navigate/a -> /after-navigate/b'); - }); -}); - -test.describe('a11y', () => { - test('keepfocus works', async ({ page }) => { - await page.goto('/keepfocus'); - - await Promise.all([ - page.locator('#input').fill('bar'), - page.waitForFunction(() => window.location.search === '?foo=bar') - ]); - await expect(page.locator('#input')).toBeFocused(); - }); -}); - -test.describe('CSS', () => { - test('applies generated component styles (hides announcer)', async ({ page, clicknav }) => { - await page.goto('/css'); - await clicknav('[href="/css/other"]'); - - expect( - await page.evaluate(() => { - const el = document.querySelector('#svelte-announcer'); - return el && getComputedStyle(el).position; - }) - ).toBe('absolute'); - }); -}); - test.describe('Endpoints', () => { test('calls a delete handler', async ({ page }) => { await page.goto('/delete-route'); @@ -409,72 +39,6 @@ test.describe('Endpoints', () => { }); }); -test.describe.serial('Errors', () => { - test('client-side load errors', async ({ page }) => { - await page.goto('/errors/load-client'); - - expect(await page.textContent('footer')).toBe('Custom layout'); - expect(await page.textContent('#message')).toBe( - 'This is your custom error page saying: "Crashing now"' - ); - }); - - test('client-side module context errors', async ({ page }) => { - await page.goto('/errors/module-scope-client'); - - expect(await page.textContent('footer')).toBe('Custom layout'); - expect(await page.textContent('#message')).toBe( - 'This is your custom error page saying: "Crashing now"' - ); - }); - - test('client-side error from load()', async ({ page }) => { - await page.goto('/errors/load-error-client'); - - expect(await page.textContent('footer')).toBe('Custom layout'); - expect(await page.textContent('#message')).toBe( - 'This is your custom error page saying: "Not found"' - ); - expect(await page.innerHTML('h1')).toBe('555'); - }); - - test('client-side 4xx status without error from load()', async ({ page }) => { - await page.goto('/errors/load-status-without-error-client'); - - expect(await page.textContent('footer')).toBe('Custom layout'); - expect(await page.textContent('#message')).toBe( - 'This is your custom error page saying: "Error: 401"' - ); - expect(await page.innerHTML('h1')).toBe('401'); - }); - - test('Root error falls back to error.html (unexpected error)', async ({ page, clicknav }) => { - await page.goto('/errors/error-html'); - await clicknav('button:text-is("Unexpected")'); - - expect(await page.textContent('h1')).toBe('Error - 500'); - expect(await page.textContent('p')).toBe( - 'This is the static error page with the following message: Failed to load' - ); - }); - - test('Root error falls back to error.html (expected error)', async ({ page, clicknav }) => { - await page.goto('/errors/error-html'); - await clicknav('button:text-is("Expected")'); - - expect(await page.textContent('h1')).toBe('Error - 401'); - expect(await page.textContent('p')).toBe( - 'This is the static error page with the following message: Not allowed' - ); - }); - - test('Root 404 redirects somewhere due to root layout', async ({ page, baseURL, clicknav }) => { - await page.goto('/errors/error-html'); - await clicknav('button:text-is("Redirect")'); - expect(page.url()).toBe(baseURL + '/load'); - }); -}); - test.describe('Load', () => { test('load function is only called when necessary', async ({ app, page }) => { await page.goto('/load/change-detection/one/a'); @@ -680,207 +244,6 @@ test.describe('Page options', () => { }); }); -test.describe('Prefetching', () => { - test('prefetches programmatically', async ({ baseURL, page, app }) => { - await page.goto('/routing/a'); - - /** @type {string[]} */ - let requests = []; - page.on('request', (r) => requests.push(r.url())); - - // also wait for network processing to complete, see - // https://playwright.dev/docs/network#network-events - await Promise.all([ - page.waitForResponse(`${baseURL}/routing/preloading/preloaded.json`), - app.preloadData('/routing/preloading/preloaded') - ]); - - // svelte request made is environment dependent - if (process.env.DEV) { - expect(requests.filter((req) => req.endsWith('+page.svelte')).length).toBe(1); - } else { - // the preload helper causes an additional request to be made in Firefox, - // so we use toBeGreaterThan rather than toBe - expect(requests.filter((req) => req.endsWith('.js')).length).toBeGreaterThan(0); - } - - expect(requests.includes(`${baseURL}/routing/preloading/preloaded.json`)).toBe(true); - - requests = []; - await app.goto('/routing/preloading/preloaded'); - expect(requests).toEqual([]); - - try { - await app.preloadData('https://example.com'); - throw new Error('Error was not thrown'); - } catch (/** @type {any} */ e) { - expect(e.message).toMatch('Attempted to preload a URL that does not belong to this app'); - } - }); - - test('chooses correct route when hash route is preloaded but regular route is clicked', async ({ - app, - page - }) => { - await page.goto('/routing/a'); - await app.preloadData('/routing/preloading/hash-route#please-dont-show-me'); - await app.goto('/routing/preloading/hash-route'); - await expect(page.locator('h1')).not.toHaveText('Oopsie'); - }); - - test('does not rerun load on calls to duplicate preload hash route', async ({ app, page }) => { - await page.goto('/routing/a'); - - await app.preloadData('/routing/preloading/hash-route#please-dont-show-me'); - await app.preloadData('/routing/preloading/hash-route#please-dont-show-me'); - await app.goto('/routing/preloading/hash-route#please-dont-show-me'); - await expect(page.locator('p')).toHaveText('Loaded 1 times.'); - }); - - test('does not rerun load on calls to different preload hash route', async ({ app, page }) => { - await page.goto('/routing/a'); - - await app.preloadData('/routing/preloading/hash-route#please-dont-show-me'); - await app.preloadData('/routing/preloading/hash-route#please-dont-show-me-jr'); - await app.goto('/routing/preloading/hash-route#please-dont-show-me'); - await expect(page.locator('p')).toHaveText('Loaded 1 times.'); - }); - - test('does rerun load when preload errored', async ({ app, page }) => { - await page.goto('/routing/a'); - - await app.preloadData('/routing/preloading/preload-error'); - await app.goto('/routing/preloading/preload-error'); - await expect(page.locator('p')).toHaveText('hello'); - }); -}); - -test.describe('Routing', () => { - test('navigates to a new page without reloading', async ({ app, page, clicknav }) => { - await page.goto('/routing'); - - await app.preloadData('/routing/a').catch((e) => { - // from error handler tests; ignore - if (!e.message.includes('Crashing now')) throw e; - }); - - /** @type {string[]} */ - const requests = []; - page.on('request', (r) => requests.push(r.url())); - - await clicknav('a[href="/routing/a"]'); - expect(await page.textContent('h1')).toBe('a'); - - expect(requests.filter((url) => !url.endsWith('/favicon.png'))).toEqual([]); - }); - - test('navigates programmatically', async ({ page, app }) => { - await page.goto('/routing/a'); - await app.goto('/routing/b'); - expect(await page.textContent('h1')).toBe('b'); - }); - - test('$page.url.hash is correctly set on page load', async ({ page }) => { - await page.goto('/routing/hashes/pagestore#target'); - expect(await page.textContent('#window-hash')).toBe('#target'); - expect(await page.textContent('#page-url-hash')).toBe('#target'); - }); - - test('$page.url.hash is correctly set on navigation', async ({ page }) => { - await page.goto('/routing/hashes/pagestore'); - expect(await page.textContent('#window-hash')).toBe(''); - expect(await page.textContent('#page-url-hash')).toBe(''); - await page.locator('[href="#target"]').click(); - expect(await page.textContent('#window-hash')).toBe('#target'); - expect(await page.textContent('#page-url-hash')).toBe('#target'); - await page.locator('[href="/routing/hashes/pagestore"]').click(); - await expect(page.locator('#window-hash')).toHaveText('#target'); // hashchange doesn't fire for these - await expect(page.locator('#page-url-hash')).toHaveText(''); - }); - - test('does not normalize external path', async ({ page, start_server }) => { - const html_ok = 'ok'; - const { port } = await start_server((_req, res) => { - res.end(html_ok); - }); - - await page.goto(`/routing/slashes?port=${port}`); - await page.locator(`a[href="http://localhost:${port}/with-slash/"]`).click(); - expect(await page.content()).toBe(html_ok); - expect(page.url()).toBe(`http://localhost:${port}/with-slash/`); - }); - - test('ignores popstate events from outside the router', async ({ page }) => { - await page.goto('/routing/external-popstate'); - expect(await page.textContent('h1')).toBe('hello'); - - await page.locator('button').click(); - expect(await page.textContent('h1')).toBe('hello'); - - await page.goBack(); - expect(await page.textContent('h1')).toBe('hello'); - - await page.goForward(); - expect(await page.textContent('h1')).toBe('hello'); - }); - - test('recognizes clicks outside the app target', async ({ page }) => { - await page.goto('/routing/link-outside-app-target/source'); - - await page.locator('[href="/routing/link-outside-app-target/target"]').click(); - await expect(page.locator('h1')).toHaveText('target: 1'); - }); - - test('responds to
submission without reload', async ({ page }) => { - await page.goto('/routing/form-get'); - expect(await page.textContent('h1')).toBe('...'); - expect(await page.textContent('h2')).toBe('enter'); - expect(await page.textContent('h3')).toBe('...'); - - /** @type {string[]} */ - const requests = []; - page.on('request', (request) => requests.push(request.url())); - - await page.locator('input').fill('updated'); - await page.locator('button').click(); - - expect(requests).toEqual([]); - expect(await page.textContent('h1')).toBe('updated'); - expect(await page.textContent('h2')).toBe('form'); - expect(await page.textContent('h3')).toBe('bar'); - }); - - test('ignores links with no href', async ({ page }) => { - await page.goto('/routing/missing-href'); - const selector = '[data-testid="count"]'; - - expect(await page.textContent(selector)).toBe('count: 1'); - - await page.locator(selector).click(); - expect(await page.textContent(selector)).toBe('count: 1'); - }); -}); - -test.describe('Shadow DOM', () => { - test('client router captures anchors in shadow dom', async ({ app, page, clicknav }) => { - await page.goto('/routing/shadow-dom'); - - await app.preloadData('/routing/a').catch((e) => { - // from error handler tests; ignore - if (!e.message.includes('Crashing now')) throw e; - }); - - /** @type {string[]} */ - const requests = []; - page.on('request', (r) => requests.push(r.url())); - - await clicknav('div[id="clickme"]'); - expect(await page.textContent('h1')).toBe('a'); - - expect(requests.filter((url) => !url.endsWith('/favicon.png'))).toEqual([]); - }); -}); - test.describe('SPA mode / no SSR', () => { test('Can use browser-only global on client-only page through ssr config in handle', async ({ page, @@ -1222,35 +585,3 @@ test.describe('Content negotiation', () => { await expect(page.locator('[data-testid="form-result"]')).toHaveText('form.submitted: true'); }); }); - -test.describe('cookies', () => { - test('etag forwards cookies', async ({ page }) => { - await page.goto('/cookies/forwarded-in-etag'); - await expect(page.locator('p')).toHaveText('foo=bar'); - await page.locator('button').click(); - await expect(page.locator('p')).toHaveText('foo=bar'); - }); -}); - -test.describe('Interactivity', () => { - test('click events on removed elements are ignored', async ({ page }) => { - let errored = false; - - page.on('pageerror', (err) => { - console.error(err); - errored = true; - }); - - await page.goto('/interactivity/toggle-element'); - expect(await page.textContent('button')).toBe('remove'); - - await page.locator('button').click(); - expect(await page.textContent('button')).toBe('add'); - expect(await page.textContent('a')).toBe('add'); - - await page.locator('a').filter({ hasText: 'add' }).click(); - expect(await page.textContent('a')).toBe('remove'); - - expect(errored).toBe(false); - }); -}); diff --git a/packages/kit/test/apps/basics/test/cross-browser/client.test.js b/packages/kit/test/apps/basics/test/cross-browser/client.test.js new file mode 100644 index 000000000000..babff5f30535 --- /dev/null +++ b/packages/kit/test/apps/basics/test/cross-browser/client.test.js @@ -0,0 +1,674 @@ +import { expect } from '@playwright/test'; +import { test } from '../../../../utils.js'; + +/** @typedef {import('@playwright/test').Response} Response */ + +test.skip(({ javaScriptEnabled }) => !javaScriptEnabled); + +test.describe.configure({ mode: 'parallel' }); + +test.describe('a11y', () => { + test('resets focus', async ({ page, clicknav, browserName }) => { + const tab = browserName === 'webkit' ? 'Alt+Tab' : 'Tab'; + + await page.goto('/accessibility/a'); + + await clicknav('[href="/accessibility/b"]'); + expect(await page.innerHTML('h1')).toBe('b'); + expect(await page.evaluate(() => (document.activeElement || {}).nodeName)).toBe('BODY'); + await page.keyboard.press(tab); + + expect(await page.evaluate(() => (document.activeElement || {}).nodeName)).toBe('BUTTON'); + expect(await page.evaluate(() => (document.activeElement || {}).textContent)).toBe('focus me'); + + await clicknav('[href="/accessibility/a"]'); + expect(await page.innerHTML('h1')).toBe('a'); + expect(await page.evaluate(() => (document.activeElement || {}).nodeName)).toBe('BODY'); + + await page.keyboard.press(tab); + expect(await page.evaluate(() => (document.activeElement || {}).nodeName)).toBe('BUTTON'); + expect(await page.evaluate(() => (document.activeElement || {}).textContent)).toBe('focus me'); + + expect(await page.evaluate(() => document.documentElement.getAttribute('tabindex'))).toBe(null); + }); + + test('applies autofocus after a navigation', async ({ page, clicknav }) => { + await page.goto('/accessibility/autofocus/a'); + + await clicknav('[href="/accessibility/autofocus/b"]'); + expect(await page.innerHTML('h1')).toBe('b'); + expect(await page.evaluate(() => (document.activeElement || {}).nodeName)).toBe('INPUT'); + }); + + test('announces client-side navigation', async ({ page, clicknav, javaScriptEnabled }) => { + await page.goto('/accessibility/a'); + + const has_live_region = (await page.innerHTML('body')).includes('aria-live'); + + if (javaScriptEnabled) { + expect(has_live_region).toBeTruthy(); + + // live region should exist, but be empty + expect(await page.innerHTML('[aria-live]')).toBe(''); + + await clicknav('[href="/accessibility/b"]'); + expect(await page.innerHTML('[aria-live]')).toBe('b'); // TODO i18n + } else { + expect(has_live_region).toBeFalsy(); + } + }); + + test('reset selection', async ({ page, clicknav }) => { + await page.goto('/selection/a'); + + expect( + await page.evaluate(() => { + const range = document.createRange(); + range.selectNodeContents(document.body); + const selection = getSelection(); + if (selection) { + selection.removeAllRanges(); + selection.addRange(range); + return selection.rangeCount; + } + return -1; + }) + ).toBe(1); + + await clicknav('[href="/selection/b"]'); + expect( + await page.evaluate(() => { + const selection = getSelection(); + if (selection) { + return selection.rangeCount; + } + return -1; + }) + ).toBe(0); + }); + + test('keepfocus works', async ({ page }) => { + await page.goto('/keepfocus'); + + await Promise.all([ + page.locator('#input').fill('bar'), + page.waitForFunction(() => window.location.search === '?foo=bar') + ]); + await expect(page.locator('#input')).toBeFocused(); + }); +}); + +test.describe('beforeNavigate', () => { + test('prevents navigation triggered by link click', async ({ page, baseURL }) => { + await page.goto('/before-navigate/prevent-navigation'); + + await page.click('[href="/before-navigate/a"]'); + await page.waitForLoadState('networkidle'); + + expect(page.url()).toBe(baseURL + '/before-navigate/prevent-navigation'); + expect(await page.innerHTML('pre')).toBe('1 false link'); + }); + + test('prevents navigation to external', async ({ page, baseURL }) => { + await page.goto('/before-navigate/prevent-navigation'); + await page.click('h1'); // The browsers block attempts to prevent navigation on a frame that's never had a user gesture. + + page.on('dialog', (dialog) => dialog.dismiss()); + + page.click('a[href="https://google.de"]'); // do NOT await this, promise only resolves after successful navigation, which never happens + await page.waitForTimeout(500); + await expect(page.locator('pre')).toHaveText('1 true link'); + expect(page.url()).toBe(baseURL + '/before-navigate/prevent-navigation'); + }); + + test('prevents navigation triggered by goto', async ({ page, app, baseURL }) => { + await page.goto('/before-navigate/prevent-navigation'); + await app.goto('/before-navigate/a'); + expect(page.url()).toBe(baseURL + '/before-navigate/prevent-navigation'); + expect(await page.innerHTML('pre')).toBe('1 false goto'); + }); + + test('prevents external navigation triggered by goto', async ({ page, app, baseURL }) => { + await page.goto('/before-navigate/prevent-navigation'); + await app.goto('https://google.de'); + expect(page.url()).toBe(baseURL + '/before-navigate/prevent-navigation'); + expect(await page.innerHTML('pre')).toBe('1 true goto'); + }); + + test('prevents navigation triggered by back button', async ({ page, app, baseURL }) => { + await page.goto('/before-navigate/a'); + await app.goto('/before-navigate/prevent-navigation'); + await page.click('h1'); // The browsers block attempts to prevent navigation on a frame that's never had a user gesture. + + await page.goBack(); + expect(await page.innerHTML('pre')).toBe('1 false popstate'); + expect(page.url()).toBe(baseURL + '/before-navigate/prevent-navigation'); + }); + + test('prevents unload', async ({ page }) => { + await page.goto('/before-navigate/prevent-navigation'); + await page.click('h1'); // The browsers block attempts to prevent navigation on a frame that's never had a user gesture. + const type = new Promise((fulfil) => { + page.on('dialog', async (dialog) => { + fulfil(dialog.type()); + await dialog.dismiss(); + }); + }); + + await page.close({ runBeforeUnload: true }); + expect(await type).toBe('beforeunload'); + expect(await page.innerHTML('pre')).toBe('1 true leave'); + }); + + test('is not triggered on redirect', async ({ page, baseURL }) => { + await page.goto('/before-navigate/prevent-navigation'); + + await page.click('[href="/before-navigate/redirect"]'); + await page.waitForLoadState('networkidle'); + + expect(page.url()).toBe(baseURL + '/before-navigate/prevent-navigation'); + expect(await page.innerHTML('pre')).toBe('1 false link'); + }); + + test('is not triggered on target=_blank', async ({ page, baseURL }) => { + await page.goto('/before-navigate/prevent-navigation'); + + await page.click('a[href="https://google.com"]'); + await page.waitForTimeout(500); + + expect(page.url()).toBe(baseURL + '/before-navigate/prevent-navigation'); + expect(await page.innerHTML('pre')).toBe('0 false undefined'); + }); +}); + +test.describe('Scrolling', () => { + test('url-supplied anchor works on direct page load', async ({ page, in_view }) => { + await page.goto('/anchor/anchor#go-to-element'); + expect(await in_view('#go-to-element')).toBe(true); + }); + + test('url-supplied anchor works on navigation to page', async ({ page, in_view, clicknav }) => { + await page.goto('/anchor'); + await clicknav('#first-anchor'); + expect(await in_view('#go-to-element')).toBe(true); + }); + + test('url-supplied anchor works when navigated from scrolled page', async ({ + page, + clicknav, + in_view + }) => { + await page.goto('/anchor'); + await clicknav('#second-anchor'); + expect(await in_view('#go-to-element')).toBe(true); + }); + + test('no-anchor url will scroll to top when navigated from scrolled page', async ({ + page, + clicknav + }) => { + await page.goto('/anchor'); + await clicknav('#third-anchor'); + expect(await page.evaluate(() => scrollY === 0)).toBeTruthy(); + }); + + test('url-supplied anchor works when navigated from bottom of page', async ({ + page, + clicknav, + in_view + }) => { + await page.goto('/anchor'); + await clicknav('#last-anchor'); + expect(await in_view('#go-to-element')).toBe(true); + }); + + test('no-anchor url will scroll to top when navigated from bottom of page', async ({ + clicknav, + page + }) => { + await page.goto('/anchor'); + await clicknav('#last-anchor-2'); + expect(await page.evaluate(() => scrollY === 0)).toBeTruthy(); + }); + + test('scroll is restored after hitting the back button', async ({ baseURL, clicknav, page }) => { + await page.goto('/anchor'); + await page.locator('#scroll-anchor').click(); + const originalScrollY = /** @type {number} */ (await page.evaluate(() => scrollY)); + await clicknav('#routing-page'); + await page.goBack(); + await page.waitForLoadState('networkidle'); + expect(page.url()).toBe(baseURL + '/anchor#last-anchor-2'); + expect(await page.evaluate(() => scrollY)).toEqual(originalScrollY); + + await page.goBack(); + await page.waitForLoadState('networkidle'); + + expect(page.url()).toBe(baseURL + '/anchor'); + expect(await page.evaluate(() => scrollY)).toEqual(0); + }); + + test('scroll is restored after hitting the back button for an in-app cross-document navigation', async ({ + page, + clicknav + }) => { + await page.goto('/scroll/cross-document/a'); + + const rect = await page.locator('[href="/scroll/cross-document/b"]').boundingBox(); + const height = await page.evaluate(() => innerHeight); + if (!rect) throw new Error('Could not determine bounding box'); + + const target_scroll_y = rect.y + rect.height - height; + await page.evaluate((y) => scrollTo(0, y), target_scroll_y); + + await page.locator('[href="/scroll/cross-document/b"]').click(); + expect(await page.textContent('h1')).toBe('b'); + await page.waitForSelector('body.started'); + + await clicknav('[href="/scroll/cross-document/c"]'); + expect(await page.textContent('h1')).toBe('c'); + + await page.goBack(); // client-side back + await page.goBack(); // native back + expect(await page.textContent('h1')).toBe('a'); + await page.waitForSelector('body.started'); + + await page.waitForTimeout(250); // needed for the test to fail reliably without the fix + + const scroll_y = await page.evaluate(() => scrollY); + + expect(Math.abs(scroll_y - target_scroll_y)).toBeLessThan(50); // we need a few pixels wiggle room, because browsers + }); + + test('url-supplied anchor is ignored with onMount() scrolling on direct page load', async ({ + page, + in_view + }) => { + await page.goto('/anchor-with-manual-scroll/anchor-onmount#go-to-element'); + expect(await in_view('#abcde')).toBe(true); + }); + + test('url-supplied anchor is ignored with afterNavigate() scrolling on direct page load', async ({ + page, + in_view, + clicknav + }) => { + await page.goto('/anchor-with-manual-scroll/anchor-afternavigate#go-to-element'); + expect(await in_view('#abcde')).toBe(true); + + await clicknav('[href="/anchor-with-manual-scroll/anchor-afternavigate?x=y#go-to-element"]'); + expect(await in_view('#abcde')).toBe(true); + }); + + test('url-supplied anchor is ignored with onMount() scrolling on navigation to page', async ({ + page, + clicknav, + javaScriptEnabled, + in_view + }) => { + await page.goto('/anchor-with-manual-scroll'); + await clicknav('[href="/anchor-with-manual-scroll/anchor-onmount#go-to-element"]'); + if (javaScriptEnabled) expect(await in_view('#abcde')).toBe(true); + else expect(await in_view('#go-to-element')).toBe(true); + }); + + test('app-supplied scroll and focus work on direct page load', async ({ page, in_view }) => { + await page.goto('/use-action/focus-and-scroll'); + expect(await in_view('#input')).toBe(true); + await expect(page.locator('#input')).toBeFocused(); + }); + + test('app-supplied scroll and focus work on navigation to page', async ({ + page, + clicknav, + in_view + }) => { + await page.goto('/use-action'); + await clicknav('[href="/use-action/focus-and-scroll"]'); + expect(await in_view('#input')).toBe(true); + await expect(page.locator('input')).toBeFocused(); + }); + + test('scroll positions are recovered on reloading the page', async ({ page, app }) => { + await page.goto('/anchor'); + await page.evaluate(() => window.scrollTo(0, 1000)); + await app.goto('/anchor/anchor'); + await page.evaluate(() => window.scrollTo(0, 1000)); + + await page.reload(); + expect(await page.evaluate(() => window.scrollY)).toBe(1000); + + await page.goBack(); + expect(await page.evaluate(() => window.scrollY)).toBe(1000); + }); + + test('scroll position is top of page on ssr:false reload', async ({ page }) => { + await page.goto('/no-ssr/margin'); + expect(await page.evaluate(() => window.scrollY)).toBe(0); + await page.reload(); + expect(await page.evaluate(() => window.scrollY)).toBe(0); + }); +}); + +test.describe('afterNavigate', () => { + test('calls callback', async ({ page, clicknav }) => { + await page.goto('/after-navigate/a'); + expect(await page.textContent('h1')).toBe('undefined -> /after-navigate/a'); + + await clicknav('[href="/after-navigate/b"]'); + expect(await page.textContent('h1')).toBe('/after-navigate/a -> /after-navigate/b'); + }); +}); + +test.describe('CSS', () => { + test('applies generated component styles (hides announcer)', async ({ page, clicknav }) => { + await page.goto('/css'); + await clicknav('[href="/css/other"]'); + + expect( + await page.evaluate(() => { + const el = document.querySelector('#svelte-announcer'); + return el && getComputedStyle(el).position; + }) + ).toBe('absolute'); + }); +}); + +test.describe.serial('Errors', () => { + test('client-side load errors', async ({ page }) => { + await page.goto('/errors/load-client'); + + expect(await page.textContent('footer')).toBe('Custom layout'); + expect(await page.textContent('#message')).toBe( + 'This is your custom error page saying: "Crashing now"' + ); + }); + + test('client-side module context errors', async ({ page }) => { + await page.goto('/errors/module-scope-client'); + + expect(await page.textContent('footer')).toBe('Custom layout'); + expect(await page.textContent('#message')).toBe( + 'This is your custom error page saying: "Crashing now"' + ); + }); + + test('client-side error from load()', async ({ page }) => { + await page.goto('/errors/load-error-client'); + + expect(await page.textContent('footer')).toBe('Custom layout'); + expect(await page.textContent('#message')).toBe( + 'This is your custom error page saying: "Not found"' + ); + expect(await page.innerHTML('h1')).toBe('555'); + }); + + test('client-side 4xx status without error from load()', async ({ page }) => { + await page.goto('/errors/load-status-without-error-client'); + + expect(await page.textContent('footer')).toBe('Custom layout'); + expect(await page.textContent('#message')).toBe( + 'This is your custom error page saying: "Error: 401"' + ); + expect(await page.innerHTML('h1')).toBe('401'); + }); + + test('Root error falls back to error.html (unexpected error)', async ({ page, clicknav }) => { + await page.goto('/errors/error-html'); + await clicknav('button:text-is("Unexpected")'); + + expect(await page.textContent('h1')).toBe('Error - 500'); + expect(await page.textContent('p')).toBe( + 'This is the static error page with the following message: Failed to load' + ); + }); + + test('Root error falls back to error.html (expected error)', async ({ page, clicknav }) => { + await page.goto('/errors/error-html'); + await clicknav('button:text-is("Expected")'); + + expect(await page.textContent('h1')).toBe('Error - 401'); + expect(await page.textContent('p')).toBe( + 'This is the static error page with the following message: Not allowed' + ); + }); + + test('Root 404 redirects somewhere due to root layout', async ({ page, baseURL, clicknav }) => { + await page.goto('/errors/error-html'); + await clicknav('button:text-is("Redirect")'); + expect(page.url()).toBe(baseURL + '/load'); + }); +}); + +test.describe('Prefetching', () => { + test('prefetches programmatically', async ({ baseURL, page, app }) => { + await page.goto('/routing/a'); + + /** @type {string[]} */ + let requests = []; + page.on('request', (r) => requests.push(r.url())); + + // also wait for network processing to complete, see + // https://playwright.dev/docs/network#network-events + await Promise.all([ + page.waitForResponse(`${baseURL}/routing/preloading/preloaded.json`), + app.preloadData('/routing/preloading/preloaded') + ]); + + // svelte request made is environment dependent + if (process.env.DEV) { + expect(requests.filter((req) => req.endsWith('+page.svelte')).length).toBe(1); + } else { + // the preload helper causes an additional request to be made in Firefox, + // so we use toBeGreaterThan rather than toBe + expect(requests.filter((req) => req.endsWith('.js')).length).toBeGreaterThan(0); + } + + expect(requests.includes(`${baseURL}/routing/preloading/preloaded.json`)).toBe(true); + + requests = []; + await app.goto('/routing/preloading/preloaded'); + expect(requests).toEqual([]); + + try { + await app.preloadData('https://example.com'); + throw new Error('Error was not thrown'); + } catch (/** @type {any} */ e) { + expect(e.message).toMatch('Attempted to preload a URL that does not belong to this app'); + } + }); + + test('chooses correct route when hash route is preloaded but regular route is clicked', async ({ + app, + page + }) => { + await page.goto('/routing/a'); + await app.preloadData('/routing/preloading/hash-route#please-dont-show-me'); + await app.goto('/routing/preloading/hash-route'); + await expect(page.locator('h1')).not.toHaveText('Oopsie'); + }); + + test('does not rerun load on calls to duplicate preload hash route', async ({ app, page }) => { + await page.goto('/routing/a'); + + await app.preloadData('/routing/preloading/hash-route#please-dont-show-me'); + await app.preloadData('/routing/preloading/hash-route#please-dont-show-me'); + await app.goto('/routing/preloading/hash-route#please-dont-show-me'); + await expect(page.locator('p')).toHaveText('Loaded 1 times.'); + }); + + test('does not rerun load on calls to different preload hash route', async ({ app, page }) => { + await page.goto('/routing/a'); + + await app.preloadData('/routing/preloading/hash-route#please-dont-show-me'); + await app.preloadData('/routing/preloading/hash-route#please-dont-show-me-jr'); + await app.goto('/routing/preloading/hash-route#please-dont-show-me'); + await expect(page.locator('p')).toHaveText('Loaded 1 times.'); + }); + + test('does rerun load when preload errored', async ({ app, page }) => { + await page.goto('/routing/a'); + + await app.preloadData('/routing/preloading/preload-error'); + await app.goto('/routing/preloading/preload-error'); + await expect(page.locator('p')).toHaveText('hello'); + }); +}); + +test.describe('Routing', () => { + test('navigates to a new page without reloading', async ({ app, page, clicknav }) => { + await page.goto('/routing'); + + await app.preloadData('/routing/a').catch((e) => { + // from error handler tests; ignore + if (!e.message.includes('Crashing now')) throw e; + }); + + /** @type {string[]} */ + const requests = []; + page.on('request', (r) => requests.push(r.url())); + + await clicknav('a[href="/routing/a"]'); + expect(await page.textContent('h1')).toBe('a'); + + expect(requests.filter((url) => !url.endsWith('/favicon.png'))).toEqual([]); + }); + + test('navigates programmatically', async ({ page, app }) => { + await page.goto('/routing/a'); + await app.goto('/routing/b'); + expect(await page.textContent('h1')).toBe('b'); + }); + + test('$page.url.hash is correctly set on page load', async ({ page }) => { + await page.goto('/routing/hashes/pagestore#target'); + expect(await page.textContent('#window-hash')).toBe('#target'); + expect(await page.textContent('#page-url-hash')).toBe('#target'); + }); + + test('$page.url.hash is correctly set on navigation', async ({ page }) => { + await page.goto('/routing/hashes/pagestore'); + expect(await page.textContent('#window-hash')).toBe(''); + expect(await page.textContent('#page-url-hash')).toBe(''); + await page.locator('[href="#target"]').click(); + expect(await page.textContent('#window-hash')).toBe('#target'); + expect(await page.textContent('#page-url-hash')).toBe('#target'); + await page.locator('[href="/routing/hashes/pagestore"]').click(); + await expect(page.locator('#window-hash')).toHaveText('#target'); // hashchange doesn't fire for these + await expect(page.locator('#page-url-hash')).toHaveText(''); + }); + + test('does not normalize external path', async ({ page, start_server }) => { + const html_ok = 'ok'; + const { port } = await start_server((_req, res) => { + res.end(html_ok); + }); + + await page.goto(`/routing/slashes?port=${port}`); + await page.locator(`a[href="http://localhost:${port}/with-slash/"]`).click(); + expect(await page.content()).toBe(html_ok); + expect(page.url()).toBe(`http://localhost:${port}/with-slash/`); + }); + + test('ignores popstate events from outside the router', async ({ page }) => { + await page.goto('/routing/external-popstate'); + expect(await page.textContent('h1')).toBe('hello'); + + await page.locator('button').click(); + expect(await page.textContent('h1')).toBe('hello'); + + await page.goBack(); + expect(await page.textContent('h1')).toBe('hello'); + + await page.goForward(); + expect(await page.textContent('h1')).toBe('hello'); + }); + + test('recognizes clicks outside the app target', async ({ page }) => { + await page.goto('/routing/link-outside-app-target/source'); + + await page.locator('[href="/routing/link-outside-app-target/target"]').click(); + await expect(page.locator('h1')).toHaveText('target: 1'); + }); + + test('responds to submission without reload', async ({ page }) => { + await page.goto('/routing/form-get'); + expect(await page.textContent('h1')).toBe('...'); + expect(await page.textContent('h2')).toBe('enter'); + expect(await page.textContent('h3')).toBe('...'); + + /** @type {string[]} */ + const requests = []; + page.on('request', (request) => requests.push(request.url())); + + await page.locator('input').fill('updated'); + await page.locator('button').click(); + + expect(requests).toEqual([]); + expect(await page.textContent('h1')).toBe('updated'); + expect(await page.textContent('h2')).toBe('form'); + expect(await page.textContent('h3')).toBe('bar'); + }); + + test('ignores links with no href', async ({ page }) => { + await page.goto('/routing/missing-href'); + const selector = '[data-testid="count"]'; + + expect(await page.textContent(selector)).toBe('count: 1'); + + await page.locator(selector).click(); + expect(await page.textContent(selector)).toBe('count: 1'); + }); +}); + +test.describe('Shadow DOM', () => { + test('client router captures anchors in shadow dom', async ({ app, page, clicknav }) => { + await page.goto('/routing/shadow-dom'); + + await app.preloadData('/routing/a').catch((e) => { + // from error handler tests; ignore + if (!e.message.includes('Crashing now')) throw e; + }); + + /** @type {string[]} */ + const requests = []; + page.on('request', (r) => requests.push(r.url())); + + await clicknav('div[id="clickme"]'); + expect(await page.textContent('h1')).toBe('a'); + + expect(requests.filter((url) => !url.endsWith('/favicon.png'))).toEqual([]); + }); +}); + +test.describe('cookies', () => { + test('etag forwards cookies', async ({ page }) => { + await page.goto('/cookies/forwarded-in-etag'); + await expect(page.locator('p')).toHaveText('foo=bar'); + await page.locator('button').click(); + await expect(page.locator('p')).toHaveText('foo=bar'); + }); +}); + +test.describe('Interactivity', () => { + test('click events on removed elements are ignored', async ({ page }) => { + let errored = false; + + page.on('pageerror', (err) => { + console.error(err); + errored = true; + }); + + await page.goto('/interactivity/toggle-element'); + expect(await page.textContent('button')).toBe('remove'); + + await page.locator('button').click(); + expect(await page.textContent('button')).toBe('add'); + expect(await page.textContent('a')).toBe('add'); + + await page.locator('a').filter({ hasText: 'add' }).click(); + expect(await page.textContent('a')).toBe('remove'); + + expect(errored).toBe(false); + }); +}); diff --git a/packages/kit/test/apps/basics/test/cross-browser/test.js b/packages/kit/test/apps/basics/test/cross-browser/test.js new file mode 100644 index 000000000000..dd9d47466b85 --- /dev/null +++ b/packages/kit/test/apps/basics/test/cross-browser/test.js @@ -0,0 +1,995 @@ +import { expect } from '@playwright/test'; +import { test } from '../../../../utils.js'; + +/** @typedef {import('@playwright/test').Response} Response */ + +test.describe.configure({ mode: 'parallel' }); + +test.describe('CSS', () => { + test('applies imported styles', async ({ page }) => { + await page.goto('/css'); + + expect( + await page.evaluate(() => { + const el = document.querySelector('.styled'); + return el && getComputedStyle(el).color; + }) + ).toBe('rgb(255, 0, 0)'); + }); + + test('applies layout styles', async ({ page }) => { + await page.goto('/css'); + + expect( + await page.evaluate(() => { + const el = document.querySelector('footer'); + return el && getComputedStyle(el).color; + }) + ).toBe('rgb(128, 0, 128)'); + }); + + test('applies local styles', async ({ page }) => { + await page.goto('/css'); + + expect( + await page.evaluate(() => { + const el = document.querySelector('.also-styled'); + return el && getComputedStyle(el).color; + }) + ).toBe('rgb(0, 0, 255)'); + }); + + test('applies imported styles in the correct order', async ({ page }) => { + await page.goto('/css'); + + const color = await page.$eval('.overridden', (el) => getComputedStyle(el).color); + expect(color).toBe('rgb(0, 128, 0)'); + }); +}); + +test.describe('Shadowed pages', () => { + test('Loads props from an endpoint', async ({ page, clicknav }) => { + await page.goto('/shadowed'); + await clicknav('[href="/shadowed/simple"]'); + expect(await page.textContent('h1')).toBe('The answer is 42'); + }); + + test('Handles GET redirects', async ({ page, clicknav }) => { + await page.goto('/shadowed'); + await clicknav('[href="/shadowed/redirect-get"]'); + expect(await page.textContent('h1')).toBe('Redirection was successful'); + }); + + test('Handles GET redirects with cookies', async ({ page, context, clicknav }) => { + await page.goto('/shadowed'); + await clicknav('[href="/shadowed/redirect-get-with-cookie"]'); + expect(await page.textContent('h1')).toBe('Redirection was successful'); + + const cookies = await context.cookies(); + expect(cookies).toEqual( + expect.arrayContaining([expect.objectContaining({ name: 'shadow-redirect', value: 'happy' })]) + ); + }); + + test('Handles GET redirects with cookies from fetch response', async ({ + page, + context, + clicknav + }) => { + await page.goto('/shadowed'); + await clicknav('[href="/shadowed/redirect-get-with-cookie-from-fetch"]'); + expect(await page.textContent('h1')).toBe('Redirection was successful'); + + const cookies = await context.cookies(); + expect(cookies).toEqual( + expect.arrayContaining([ + expect.objectContaining({ name: 'shadow-redirect-fetch', value: 'happy' }) + ]) + ); + }); + + test('Handles POST redirects', async ({ page, clicknav }) => { + await page.goto('/shadowed'); + await clicknav('#redirect-post'); + expect(await page.textContent('h1')).toBe('Redirection was successful'); + }); + + test('Handles POST redirects with cookies', async ({ page, context, clicknav }) => { + await page.goto('/shadowed'); + await clicknav('#redirect-post-with-cookie'); + expect(await page.textContent('h1')).toBe('Redirection was successful'); + + const cookies = await context.cookies(); + expect(cookies).toEqual( + expect.arrayContaining([expect.objectContaining({ name: 'shadow-redirect', value: 'happy' })]) + ); + }); + + test('Handles POST success with returned location', async ({ page, clicknav }) => { + await page.goto('/shadowed/post-success-redirect'); + await clicknav('button'); + expect(await page.textContent('h1')).toBe('POST was successful'); + }); + + test('Renders error page for 4xx and 5xx responses from GET', async ({ page, clicknav }) => { + await page.goto('/shadowed'); + await clicknav('[href="/shadowed/error-get"]'); + expect(await page.textContent('h1')).toBe('404'); + }); + + test('Merges bodies for 4xx and 5xx responses from non-GET', async ({ page }) => { + await page.goto('/shadowed'); + const [response] = await Promise.all([page.waitForNavigation(), page.click('#error-post')]); + expect(await page.textContent('h1')).toBe('hello from get / echo: posted data'); + + expect(response?.status()).toBe(400); + expect(await page.textContent('h2')).toBe('status: 400'); + }); + + test('Endpoint receives consistent URL', async ({ baseURL, page, clicknav }) => { + await page.goto('/shadowed/same-render-entry'); + await clicknav('[href="/shadowed/same-render?param1=value1"]'); + expect(await page.textContent('h1')).toBe(`URL: ${baseURL}/shadowed/same-render?param1=value1`); + }); + + test('Works with missing get handler', async ({ page, clicknav }) => { + await page.goto('/shadowed'); + await clicknav('[href="/shadowed/no-get"]'); + expect(await page.textContent('h1')).toBe('hello'); + }); + + test('Invalidates shadow data when URL changes', async ({ page, clicknav }) => { + await page.goto('/shadowed'); + await clicknav('[href="/shadowed/dynamic/foo"]'); + expect(await page.textContent('h1')).toBe('slug: foo'); + + await clicknav('[href="/shadowed/dynamic/bar"]'); + expect(await page.textContent('h1')).toBe('slug: bar'); + + await page.goto('/shadowed/dynamic/foo'); + expect(await page.textContent('h1')).toBe('slug: foo'); + await clicknav('[href="/shadowed/dynamic/bar"]'); + expect(await page.textContent('h1')).toBe('slug: bar'); + }); + + test('Shadow redirect', async ({ page, clicknav }) => { + await page.goto('/shadowed/redirect'); + await clicknav('[href="/shadowed/redirect/a"]'); + expect(await page.textContent('h1')).toBe('done'); + }); + + test('Endpoint without GET', async ({ page, clicknav, baseURL, javaScriptEnabled }) => { + await page.goto('/shadowed'); + + /** @type {string[]} */ + const requests = []; + page.on('request', (r) => requests.push(r.url())); + + await clicknav('[href="/shadowed/missing-get"]'); + + expect(await page.textContent('h1')).toBe('post without get'); + + // check that the router didn't fall back to the server + if (javaScriptEnabled) { + expect(requests).not.toContain(`${baseURL}/shadowed/missing-get`); + } + }); + + test('Parent data is present', async ({ page, clicknav }) => { + await page.goto('/shadowed/parent'); + await expect(page.locator('h2')).toHaveText( + 'Layout data: {"foo":{"bar":"Custom layout"},"layout":"layout"}' + ); + await expect(page.locator('p')).toHaveText( + 'Page data: {"foo":{"bar":"Custom layout"},"layout":"layout","page":"page","data":{"rootlayout":"rootlayout","layout":"layout"}}' + ); + + await clicknav('[href="/shadowed/parent?test"]'); + await expect(page.locator('h2')).toHaveText( + 'Layout data: {"foo":{"bar":"Custom layout"},"layout":"layout"}' + ); + await expect(page.locator('p')).toHaveText( + 'Page data: {"foo":{"bar":"Custom layout"},"layout":"layout","page":"page","data":{"rootlayout":"rootlayout","layout":"layout"}}' + ); + + await clicknav('[href="/shadowed/parent/sub"]'); + await expect(page.locator('h2')).toHaveText( + 'Layout data: {"foo":{"bar":"Custom layout"},"layout":"layout"}' + ); + await expect(page.locator('p')).toHaveText( + 'Page data: {"foo":{"bar":"Custom layout"},"layout":"layout","sub":"sub","data":{"rootlayout":"rootlayout","layout":"layout"}}' + ); + }); + + if (process.env.DEV) { + test('Data must be serializable', async ({ page, clicknav }) => { + await page.goto('/shadowed'); + await clicknav('[href="/shadowed/serialization"]'); + + expect(await page.textContent('h1')).toBe('500'); + expect(await page.textContent('#message')).toBe( + 'This is your custom error page saying: "Data returned from `load` while rendering /shadowed/serialization is not serializable: Cannot stringify arbitrary non-POJOs (data.nope)"' + ); + }); + } +}); + +test.describe('Errors', () => { + if (process.env.DEV) { + // TODO these probably shouldn't have the full render treatment, + // given that they will never be user-visible in prod + test('server-side errors', async ({ page }) => { + await page.goto('/errors/serverside'); + + expect(await page.textContent('footer')).toBe('Custom layout'); + expect(await page.textContent('#message')).toBe( + 'This is your custom error page saying: "Crashing now"' + ); + }); + + test('server-side module context errors', async ({ page }) => { + test.fixme(); + + await page.goto('/errors/module-scope-server'); + + expect(await page.textContent('footer')).toBe('Custom layout'); + expect(await page.textContent('#message')).toBe( + 'This is your custom error page saying: "Crashing now"' + ); + }); + + test('errors on invalid load function response', async ({ page, app, javaScriptEnabled }) => { + if (javaScriptEnabled) { + await page.goto('/'); + await app.goto('/errors/invalid-load-response'); + } else { + await page.goto('/errors/invalid-load-response'); + } + + expect(await page.textContent('footer')).toBe('Custom layout'); + expect(await page.textContent('#message')).toBe( + 'This is your custom error page saying: "a load function related to route \'/errors/invalid-load-response\' returned an array, but must return a plain object at the top level (i.e. `return {...}`)"' + ); + }); + + test('errors on invalid server load function response', async ({ + page, + app, + javaScriptEnabled + }) => { + if (javaScriptEnabled) { + await page.goto('/'); + await app.goto('/errors/invalid-server-load-response'); + } else { + await page.goto('/errors/invalid-server-load-response'); + } + + expect(await page.textContent('footer')).toBe('Custom layout'); + expect(await page.textContent('#message')).toBe( + 'This is your custom error page saying: "a load function related to route \'/errors/invalid-server-load-response\' returned an array, but must return a plain object at the top level (i.e. `return {...}`)"' + ); + }); + } + + test('server-side load errors', async ({ page }) => { + await page.goto('/errors/load-server'); + + expect(await page.textContent('footer')).toBe('Custom layout'); + expect(await page.textContent('#message')).toBe( + 'This is your custom error page saying: "Crashing now"' + ); + + expect( + await page.evaluate(() => { + const el = document.querySelector('h1'); + return el && getComputedStyle(el).color; + }) + ).toBe('rgb(255, 0, 0)'); + }); + + test('404', async ({ page }) => { + const response = await page.goto('/why/would/anyone/fetch/this/url'); + + expect(await page.textContent('footer')).toBe('Custom layout'); + expect(await page.textContent('#message')).toBe( + 'This is your custom error page saying: "Not found: /why/would/anyone/fetch/this/url"' + ); + expect(/** @type {Response} */ (response).status()).toBe(404); + }); + + test('server-side error from load() is a string', async ({ page }) => { + const response = await page.goto('/errors/load-error-string-server'); + + expect(await page.textContent('footer')).toBe('Custom layout'); + expect(await page.textContent('#message')).toBe( + 'This is your custom error page saying: "Not found"' + ); + expect(/** @type {Response} */ (response).status()).toBe(555); + }); + + test('server-side error from load() is an Error', async ({ page }) => { + const response = await page.goto('/errors/load-error-server'); + + expect(await page.textContent('footer')).toBe('Custom layout'); + expect(await page.textContent('#message')).toBe( + 'This is your custom error page saying: "Not found"' + ); + expect(/** @type {Response} */ (response).status()).toBe(555); + }); + + test('error in endpoint', async ({ page, read_errors }) => { + const res = await page.goto('/errors/endpoint'); + + // should include stack trace + const lines = read_errors('/errors/endpoint.json').stack.split('\n'); + expect(lines[0]).toMatch('nope'); + + if (process.env.DEV) { + expect(lines[1]).toMatch('endpoint.json'); + } + + expect(res && res.status()).toBe(500); + expect(await page.textContent('#message')).toBe('This is your custom error page saying: "500"'); + }); + + test('error in shadow endpoint', async ({ page, read_errors }) => { + const res = await page.goto('/errors/endpoint-shadow'); + + // should include stack trace + const lines = read_errors('/errors/endpoint-shadow').stack.split('\n'); + expect(lines[0]).toMatch('nope'); + + if (process.env.DEV) { + expect(lines[1]).toMatch('+page.server.js:3:8'); + } + + expect(res && res.status()).toBe(500); + expect(await page.textContent('#message')).toBe( + 'This is your custom error page saying: "nope"' + ); + }); + + test('not ok response from shadow endpoint', async ({ page, read_errors }) => { + const res = await page.goto('/errors/endpoint-shadow-not-ok'); + + expect(read_errors('/errors/endpoint-shadow-not-ok')).toBeUndefined(); + + expect(res && res.status()).toBe(555); + expect(await page.textContent('#message')).toBe( + 'This is your custom error page saying: "Error: 555"' + ); + }); + + test('prerendering a page with a mutative page endpoint results in a catchable error', async ({ + page + }) => { + await page.goto('/prerendering/mutative-endpoint'); + expect(await page.textContent('h1')).toBe('500'); + + expect(await page.textContent('#message')).toBe( + 'This is your custom error page saying: "Cannot prerender pages with actions"' + ); + }); + + test('page endpoint GET thrown error message is preserved', async ({ + page, + clicknav, + read_errors + }) => { + await page.goto('/errors/page-endpoint'); + await clicknav('#get-implicit'); + + expect(await page.textContent('pre')).toBe( + JSON.stringify({ status: 500, message: 'oops' }, null, ' ') + ); + + const { status, name, message, stack, fancy } = read_errors( + '/errors/page-endpoint/get-implicit' + ); + expect(status).toBe(undefined); + expect(name).toBe('FancyError'); + expect(message).toBe('oops'); + expect(fancy).toBe(true); + if (process.env.DEV) { + const lines = stack.split('\n'); + expect(lines[1]).toContain('+page.server.js:4:8'); + } + }); + + test('page endpoint GET HttpError message is preserved', async ({ + page, + clicknav, + read_errors + }) => { + await page.goto('/errors/page-endpoint'); + await clicknav('#get-explicit'); + + expect(await page.textContent('pre')).toBe( + JSON.stringify({ status: 400, message: 'oops' }, null, ' ') + ); + + const error = read_errors('/errors/page-endpoint/get-explicit'); + expect(error).toBe(undefined); + }); + + test('page endpoint POST unexpected error message is preserved', async ({ + page, + clicknav, + read_errors + }) => { + // The case where we're submitting a POST request via a form. + // It should show the __error template with our message. + await page.goto('/errors/page-endpoint'); + await clicknav('#post-implicit'); + + expect(await page.textContent('pre')).toBe( + JSON.stringify({ status: 500, message: 'oops' }, null, ' ') + ); + + const { status, name, message, stack, fancy } = read_errors( + '/errors/page-endpoint/post-implicit' + ); + + expect(status).toBe(undefined); + expect(name).toBe('FancyError'); + expect(message).toBe('oops'); + expect(fancy).toBe(true); + if (process.env.DEV) { + const lines = stack.split('\n'); + expect(lines[1]).toContain('+page.server.js:6:9'); + } + }); + + test('page endpoint POST HttpError error message is preserved', async ({ + page, + clicknav, + read_errors + }) => { + // The case where we're submitting a POST request via a form. + // It should show the __error template with our message. + await page.goto('/errors/page-endpoint'); + await clicknav('#post-explicit'); + + expect(await page.textContent('pre')).toBe( + JSON.stringify({ status: 400, message: 'oops' }, null, ' ') + ); + + const error = read_errors('/errors/page-endpoint/post-explicit'); + expect(error).toBe(undefined); + }); +}); + +test.describe('Headers', () => { + test('allows headers to be sent as a Headers class instead of a POJO', async ({ page }) => { + await page.goto('/headers/class'); + expect(await page.innerHTML('p')).toBe('bar'); + }); +}); + +test.describe('Redirects', () => { + test('redirect', async ({ baseURL, page, clicknav }) => { + await page.goto('/redirect'); + + await clicknav('[href="/redirect/a"]'); + + await page.waitForURL('/redirect/c'); + expect(await page.textContent('h1')).toBe('c'); + expect(page.url()).toBe(`${baseURL}/redirect/c`); + + await page.goBack(); + expect(page.url()).toBe(`${baseURL}/redirect`); + }); + + test('prevents redirect loops', async ({ baseURL, page, javaScriptEnabled, browserName }) => { + await page.goto('/redirect'); + + await page.locator('[href="/redirect/loopy/a"]').click(); + + if (javaScriptEnabled) { + await page.waitForSelector('#message'); + expect(page.url()).toBe(`${baseURL}/redirect/loopy/a`); + expect(await page.textContent('h1')).toBe('500'); + expect(await page.textContent('#message')).toBe( + 'This is your custom error page saying: "Redirect loop"' + ); + } else { + // there's not a lot we can do to handle server-side redirect loops + if (browserName === 'chromium') { + expect(page.url()).toBe('chrome-error://chromewebdata/'); + } else { + expect(page.url()).toBe(`${baseURL}/redirect`); + } + } + }); + + test('errors on missing status', async ({ + baseURL, + page, + clicknav, + javaScriptEnabled, + read_errors + }) => { + await page.goto('/redirect'); + + await clicknav('[href="/redirect/missing-status/a"]'); + + const message = process.env.DEV || !javaScriptEnabled ? 'Invalid status code' : 'Redirect loop'; + + expect(page.url()).toBe(`${baseURL}/redirect/missing-status/a`); + expect(await page.textContent('h1')).toBe('500'); + expect(await page.textContent('#message')).toBe( + `This is your custom error page saying: "${message}"` + ); + + if (!javaScriptEnabled) { + // handleError is not invoked for client-side navigation + const lines = read_errors('/redirect/missing-status/a').stack.split('\n'); + expect(lines[0]).toBe(`Error: ${message}`); + } + }); + + test('errors on invalid status', async ({ baseURL, page, clicknav, javaScriptEnabled }) => { + await page.goto('/redirect'); + + await clicknav('[href="/redirect/missing-status/b"]'); + + const message = process.env.DEV || !javaScriptEnabled ? 'Invalid status code' : 'Redirect loop'; + + expect(page.url()).toBe(`${baseURL}/redirect/missing-status/b`); + expect(await page.textContent('h1')).toBe('500'); + expect(await page.textContent('#message')).toBe( + `This is your custom error page saying: "${message}"` + ); + }); + + test('redirect-on-load', async ({ baseURL, page, javaScriptEnabled }) => { + const redirected_to_url = javaScriptEnabled + ? `${baseURL}/redirect-on-load/redirected` + : `${baseURL}/redirect-on-load`; + + await Promise.all([page.waitForResponse(redirected_to_url), page.goto('/redirect-on-load')]); + + expect(page.url()).toBe(redirected_to_url); + + if (javaScriptEnabled) { + expect(await page.textContent('h1')).toBe('Hazaa!'); + } + }); + + test('redirect response in handle hook', async ({ baseURL, clicknav, page }) => { + await page.goto('/redirect'); + + await clicknav('[href="/redirect/in-handle?response"]'); + + await page.waitForURL('/redirect/c'); + expect(await page.textContent('h1')).toBe('c'); + expect(page.url()).toBe(`${baseURL}/redirect/c`); + + await page.goBack(); + expect(page.url()).toBe(`${baseURL}/redirect`); + }); + + test('throw redirect in handle hook', async ({ baseURL, clicknav, page }) => { + await page.goto('/redirect'); + + await clicknav('[href="/redirect/in-handle?throw"]'); + + await page.waitForURL('/redirect/c'); + expect(await page.textContent('h1')).toBe('c'); + expect(page.url()).toBe(`${baseURL}/redirect/c`); + + await page.goBack(); + expect(page.url()).toBe(`${baseURL}/redirect`); + }); +}); + +test.describe('Routing', () => { + test('redirects from /routing/ to /routing', async ({ + baseURL, + page, + clicknav, + app, + javaScriptEnabled + }) => { + await page.goto('/routing/slashes'); + + await clicknav('a[href="/routing/"]'); + expect(page.url()).toBe(`${baseURL}/routing`); + expect(await page.textContent('h1')).toBe('Great success!'); + + if (javaScriptEnabled) { + await page.goto(`${baseURL}/routing/slashes`); + await app.goto('/routing/'); + expect(page.url()).toBe(`${baseURL}/routing`); + expect(await page.textContent('h1')).toBe('Great success!'); + } + }); + + test('redirects from /routing/? to /routing', async ({ + baseURL, + page, + clicknav, + app, + javaScriptEnabled + }) => { + await page.goto('/routing/slashes'); + + await clicknav('a[href="/routing/?"]'); + expect(page.url()).toBe(`${baseURL}/routing`); + expect(await page.textContent('h1')).toBe('Great success!'); + + if (javaScriptEnabled) { + await page.goto(`${baseURL}/routing/slashes`); + await app.goto('/routing/?'); + expect(page.url()).toBe(`${baseURL}/routing`); + expect(await page.textContent('h1')).toBe('Great success!'); + } + }); + + test('redirects from /routing/?foo=bar to /routing?foo=bar', async ({ + baseURL, + page, + clicknav, + app, + javaScriptEnabled + }) => { + await page.goto('/routing/slashes'); + + await clicknav('a[href="/routing/?foo=bar"]'); + expect(page.url()).toBe(`${baseURL}/routing?foo=bar`); + expect(await page.textContent('h1')).toBe('Great success!'); + + if (javaScriptEnabled) { + await page.goto(`${baseURL}/routing/slashes`); + await app.goto('/routing/?foo=bar'); + expect(page.url()).toBe(`${baseURL}/routing?foo=bar`); + expect(await page.textContent('h1')).toBe('Great success!'); + } + }); + + test('serves static route', async ({ page }) => { + await page.goto('/routing/a'); + expect(await page.textContent('h1')).toBe('a'); + }); + + test('serves static route from dir/index.html file', async ({ page }) => { + await page.goto('/routing/b'); + expect(await page.textContent('h1')).toBe('b'); + }); + + test('serves static route under client directory', async ({ baseURL, page }) => { + await page.goto('/routing/client/foo'); + + expect(await page.textContent('h1')).toBe('foo'); + + await page.goto(`${baseURL}/routing/client/bar`); + expect(await page.textContent('h1')).toBe('bar'); + + await page.goto(`${baseURL}/routing/client/bar/b`); + expect(await page.textContent('h1')).toBe('b'); + }); + + test('serves dynamic route', async ({ page }) => { + await page.goto('/routing/test-slug'); + expect(await page.textContent('h1')).toBe('test-slug'); + }); + + test('does not attempt client-side navigation to server routes', async ({ page }) => { + await page.goto('/routing'); + await page.locator('[href="/routing/ambiguous/ok.json"]').click(); + expect(await page.textContent('body')).toBe('ok'); + }); + + test('does not attempt client-side navigation to links with data-sveltekit-reload', async ({ + baseURL, + page, + clicknav + }) => { + await page.goto('/routing'); + + /** @type {string[]} */ + const requests = []; + page.on('request', (r) => requests.push(r.url())); + + await clicknav('[href="/routing/b"]'); + expect(await page.textContent('h1')).toBe('b'); + expect(requests).toContain(`${baseURL}/routing/b`); + }); + + test('allows reserved words as route names', async ({ page }) => { + await page.goto('/routing/const'); + expect(await page.textContent('h1')).toBe('reserved words are okay as routes'); + }); + + test('resets the active element after navigation', async ({ page, clicknav }) => { + await page.goto('/routing'); + await clicknav('[href="/routing/a"]'); + await page.waitForFunction(() => (document.activeElement || {}).nodeName == 'BODY'); + }); + + test('navigates between routes with empty parts', async ({ page, clicknav }) => { + await page.goto('/routing/dirs/foo'); + expect(await page.textContent('h1')).toBe('foo'); + await clicknav('[href="bar"]'); + expect(await page.textContent('h1')).toBe('bar'); + }); + + test('navigates between dynamic routes with same segments', async ({ page, clicknav }) => { + await page.goto('/routing/dirs/bar/xyz'); + expect(await page.textContent('h1')).toBe('A page'); + + await clicknav('[href="/routing/dirs/foo/xyz"]'); + expect(await page.textContent('h1')).toBe('B page'); + }); + + test('invalidates page when a segment is skipped', async ({ page, clicknav }) => { + await page.goto('/routing/skipped/x/1'); + expect(await page.textContent('h1')).toBe('x/1'); + + await clicknav('#goto-y1'); + expect(await page.textContent('h1')).toBe('y/1'); + }); + + test('back button returns to initial route', async ({ page, clicknav }) => { + await page.goto('/routing'); + await clicknav('[href="/routing/a"]'); + + await page.goBack(); + await page.waitForLoadState('networkidle'); + expect(await page.textContent('h1')).toBe('Great success!'); + }); + + test('back button returns to previous route when previous route has been navigated to via hash anchor', async ({ + page, + clicknav + }) => { + await page.goto('/routing/hashes/a'); + + await page.locator('[href="#hash-target"]').click(); + await clicknav('[href="/routing/hashes/b"]'); + + await page.goBack(); + expect(await page.textContent('h1')).toBe('a'); + }); + + test('focus works if page load has hash', async ({ page, browserName }) => { + await page.goto('/routing/hashes/target#p2'); + + await page.keyboard.press(browserName === 'webkit' ? 'Alt+Tab' : 'Tab'); + await page.waitForTimeout(50); // give browser a bit of time to complete the native behavior of the key press + expect( + await page.evaluate( + () => document.activeElement?.textContent || 'ERROR: document.activeElement not set' + ) + ).toBe('next focus element'); + }); + + test('focus works when navigating to a hash on the same page', async ({ page, browserName }) => { + await page.goto('/routing/hashes/target'); + + await page.click('[href="#p2"]'); + await page.keyboard.press(browserName === 'webkit' ? 'Alt+Tab' : 'Tab'); + + expect(await page.evaluate(() => (document.activeElement || {}).textContent)).toBe( + 'next focus element' + ); + }); + + test(':target pseudo-selector works when navigating to a hash on the same page', async ({ + page + }) => { + await page.goto('/routing/hashes/target#p1'); + + expect( + await page.evaluate(() => { + const el = document.getElementById('p1'); + return el && getComputedStyle(el).color; + }) + ).toBe('rgb(255, 0, 0)'); + await page.click('[href="#p2"]'); + expect( + await page.evaluate(() => { + const el = document.getElementById('p2'); + return el && getComputedStyle(el).color; + }) + ).toBe('rgb(255, 0, 0)'); + }); + + test('last parameter in a segment wins in cases of ambiguity', async ({ page, clicknav }) => { + await page.goto('/routing/split-params'); + await clicknav('[href="/routing/split-params/x-y-z"]'); + expect(await page.textContent('h1')).toBe('x'); + expect(await page.textContent('h2')).toBe('y-z'); + }); + + test('ignores navigation to URLs the app does not own', async ({ page, start_server }) => { + const { port } = await start_server((req, res) => res.end('ok')); + + await page.goto(`/routing?port=${port}`); + await Promise.all([ + page.click(`[href="http://localhost:${port}"]`), + // assert that the app can visit a URL not owned by the app without crashing + page.waitForURL(`http://localhost:${port}/`) + ]); + }); + + test('navigates to ...rest', async ({ page, clicknav }) => { + await page.goto('/routing/rest/abc/xyz'); + + expect(await page.textContent('h1')).toBe('abc/xyz'); + + await clicknav('[href="/routing/rest/xyz/abc/def/ghi"]'); + expect(await page.textContent('h1')).toBe('xyz/abc/def/ghi'); + expect(await page.textContent('h2')).toBe('xyz/abc/def/ghi'); + + await clicknav('[href="/routing/rest/xyz/abc/def"]'); + expect(await page.textContent('h1')).toBe('xyz/abc/def'); + expect(await page.textContent('h2')).toBe('xyz/abc/def'); + + await clicknav('[href="/routing/rest/xyz/abc"]'); + expect(await page.textContent('h1')).toBe('xyz/abc'); + expect(await page.textContent('h2')).toBe('xyz/abc'); + + await clicknav('[href="/routing/rest"]'); + expect(await page.textContent('h1')).toBe(''); + expect(await page.textContent('h2')).toBe(''); + + await clicknav('[href="/routing/rest/xyz/abc/deep"]'); + expect(await page.textContent('h1')).toBe('xyz/abc'); + expect(await page.textContent('h2')).toBe('xyz/abc'); + + await page.locator('[href="/routing/rest/xyz/abc/qwe/deep.json"]').click(); + expect(await page.textContent('body')).toBe('xyz/abc/qwe'); + }); + + test('rest parameters do not swallow characters', async ({ page, clicknav }) => { + await page.goto('/routing/rest/non-greedy'); + + await clicknav('[href="/routing/rest/non-greedy/foo/one/two"]'); + expect(await page.textContent('h1')).toBe('non-greedy'); + expect(await page.textContent('h2')).toBe('{"rest":"one/two"}'); + + await clicknav('[href="/routing/rest/non-greedy/food/one/two"]'); + expect(await page.textContent('h1')).not.toBe('non-greedy'); + + await page.goBack(); + + await clicknav('[href="/routing/rest/non-greedy/one-bar/two/three"]'); + expect(await page.textContent('h1')).toBe('non-greedy'); + expect(await page.textContent('h2')).toBe('{"dynamic":"one","rest":"two/three"}'); + + await clicknav('[href="/routing/rest/non-greedy/one-bard/two/three"]'); + expect(await page.textContent('h1')).not.toBe('non-greedy'); + }); + + test('reloads when navigating between ...rest pages', async ({ page, clicknav }) => { + await page.goto('/routing/rest/path/one'); + expect(await page.textContent('h1')).toBe('path: /routing/rest/path/one'); + + await clicknav('[href="/routing/rest/path/two"]'); + expect(await page.textContent('h1')).toBe('path: /routing/rest/path/two'); + + await clicknav('[href="/routing/rest/path/three"]'); + expect(await page.textContent('h1')).toBe('path: /routing/rest/path/three'); + }); + + test('allows rest routes to have prefixes and suffixes', async ({ page }) => { + await page.goto('/routing/rest/complex/prefix-one/two/three'); + expect(await page.textContent('h1')).toBe('parts: one/two/three'); + }); + + test('links to unmatched routes result in a full page navigation, not a 404', async ({ + page, + clicknav + }) => { + await page.goto('/routing'); + await clicknav('[href="/static.json"]'); + expect(await page.textContent('body')).toBe('"static file"\n'); + }); + + test('navigation is cancelled upon subsequent navigation', async ({ + baseURL, + page, + clicknav + }) => { + await page.goto('/routing/cancellation'); + await page.locator('[href="/routing/cancellation/a"]').click(); + await clicknav('[href="/routing/cancellation/b"]'); + + expect(await page.url()).toBe(`${baseURL}/routing/cancellation/b`); + + await page.evaluate('window.fulfil_navigation && window.fulfil_navigation()'); + expect(await page.url()).toBe(`${baseURL}/routing/cancellation/b`); + }); + + test('Relative paths are relative to the current URL', async ({ page, clicknav }) => { + await page.goto('/iframes'); + await clicknav('[href="/iframes/nested/parent"]'); + + expect(await page.frameLocator('iframe').locator('h1').textContent()).toBe( + 'Hello from the child' + ); + }); + + test('exposes page.route.id', async ({ page, clicknav }) => { + await page.goto('/routing/route-id'); + await clicknav('[href="/routing/route-id/foo"]'); + + expect(await page.textContent('h1')).toBe('route.id in load: /routing/route-id/[x]'); + expect(await page.textContent('h2')).toBe('route.id in store: /routing/route-id/[x]'); + }); + + test('serves a page that clashes with a root directory', async ({ page }) => { + await page.goto('/static'); + expect(await page.textContent('h1')).toBe('hello'); + }); + + test('shows "Not Found" in 404 case', async ({ page }) => { + await page.goto('/404-fallback'); + expect(await page.textContent('h1')).toBe('404'); + expect(await page.textContent('p')).toBe('This is your custom error page saying: "Not Found"'); + }); + + if (process.platform !== 'win32') { + test('Respects symlinks', async ({ page, clicknav }) => { + await page.goto('/routing'); + await clicknav('[href="/routing/symlink-from"]'); + + expect(await page.textContent('h1')).toBe('symlinked'); + }); + } +}); + +test.describe('XSS', () => { + test('replaces %sveltekit.xxx% tags safely', async ({ page }) => { + await page.goto('/unsafe-replacement'); + + const content = await page.textContent('body'); + expect(content).toMatch('$& $&'); + }); + + test('escapes inline data', async ({ page, javaScriptEnabled }) => { + await page.goto('/xss'); + + expect(await page.textContent('h1')).toBe( + 'user.name is ' + ); + + if (!javaScriptEnabled) { + // @ts-expect-error - check global injected variable + expect(await page.evaluate(() => window.pwned)).toBeUndefined(); + } + }); + + const uri_xss_payload = ''; + const uri_xss_payload_encoded = encodeURIComponent(uri_xss_payload); + + test('no xss via dynamic route path', async ({ page }) => { + await page.goto(`/xss/${uri_xss_payload_encoded}`); + + expect(await page.textContent('h1')).toBe(uri_xss_payload); + + // @ts-expect-error - check global injected variable + expect(await page.evaluate(() => window.pwned)).toBeUndefined(); + }); + + test('no xss via query param', async ({ page }) => { + await page.goto(`/xss/query?key=${uri_xss_payload_encoded}`); + + expect(await page.textContent('#one')).toBe(JSON.stringify({ key: [uri_xss_payload] })); + expect(await page.textContent('#two')).toBe(JSON.stringify({ key: [uri_xss_payload] })); + + // @ts-expect-error - check global injected variable + expect(await page.evaluate(() => window.pwned)).toBeUndefined(); + }); + + test('no xss via shadow endpoint', async ({ page }) => { + await page.goto('/xss/shadow'); + + // @ts-expect-error - check global injected variable + expect(await page.evaluate(() => window.pwned)).toBeUndefined(); + expect(await page.textContent('h1')).toBe( + 'user.name is ' + ); + }); +}); diff --git a/packages/kit/test/apps/basics/test/test.js b/packages/kit/test/apps/basics/test/test.js index 0ae15fd6354b..86bb1cdade87 100644 --- a/packages/kit/test/apps/basics/test/test.js +++ b/packages/kit/test/apps/basics/test/test.js @@ -3,6 +3,8 @@ import { test } from '../../../utils.js'; /** @typedef {import('@playwright/test').Response} Response */ +test.skip(() => process.env.KIT_E2E_BROWSER === 'webkit'); + test.describe.configure({ mode: 'parallel' }); test.describe('Imports', () => { @@ -32,215 +34,6 @@ test.describe('Imports', () => { }); }); -test.describe('CSS', () => { - test('applies imported styles', async ({ page }) => { - await page.goto('/css'); - - expect( - await page.evaluate(() => { - const el = document.querySelector('.styled'); - return el && getComputedStyle(el).color; - }) - ).toBe('rgb(255, 0, 0)'); - }); - - test('applies layout styles', async ({ page }) => { - await page.goto('/css'); - - expect( - await page.evaluate(() => { - const el = document.querySelector('footer'); - return el && getComputedStyle(el).color; - }) - ).toBe('rgb(128, 0, 128)'); - }); - - test('applies local styles', async ({ page }) => { - await page.goto('/css'); - - expect( - await page.evaluate(() => { - const el = document.querySelector('.also-styled'); - return el && getComputedStyle(el).color; - }) - ).toBe('rgb(0, 0, 255)'); - }); - - test('applies imported styles in the correct order', async ({ page }) => { - await page.goto('/css'); - - const color = await page.$eval('.overridden', (el) => getComputedStyle(el).color); - expect(color).toBe('rgb(0, 128, 0)'); - }); -}); - -test.describe('Shadowed pages', () => { - test('Loads props from an endpoint', async ({ page, clicknav }) => { - await page.goto('/shadowed'); - await clicknav('[href="/shadowed/simple"]'); - expect(await page.textContent('h1')).toBe('The answer is 42'); - }); - - test('Handles GET redirects', async ({ page, clicknav }) => { - await page.goto('/shadowed'); - await clicknav('[href="/shadowed/redirect-get"]'); - expect(await page.textContent('h1')).toBe('Redirection was successful'); - }); - - test('Handles GET redirects with cookies', async ({ page, context, clicknav }) => { - await page.goto('/shadowed'); - await clicknav('[href="/shadowed/redirect-get-with-cookie"]'); - expect(await page.textContent('h1')).toBe('Redirection was successful'); - - const cookies = await context.cookies(); - expect(cookies).toEqual( - expect.arrayContaining([expect.objectContaining({ name: 'shadow-redirect', value: 'happy' })]) - ); - }); - - test('Handles GET redirects with cookies from fetch response', async ({ - page, - context, - clicknav - }) => { - await page.goto('/shadowed'); - await clicknav('[href="/shadowed/redirect-get-with-cookie-from-fetch"]'); - expect(await page.textContent('h1')).toBe('Redirection was successful'); - - const cookies = await context.cookies(); - expect(cookies).toEqual( - expect.arrayContaining([ - expect.objectContaining({ name: 'shadow-redirect-fetch', value: 'happy' }) - ]) - ); - }); - - test('Handles POST redirects', async ({ page, clicknav }) => { - await page.goto('/shadowed'); - await clicknav('#redirect-post'); - expect(await page.textContent('h1')).toBe('Redirection was successful'); - }); - - test('Handles POST redirects with cookies', async ({ page, context, clicknav }) => { - await page.goto('/shadowed'); - await clicknav('#redirect-post-with-cookie'); - expect(await page.textContent('h1')).toBe('Redirection was successful'); - - const cookies = await context.cookies(); - expect(cookies).toEqual( - expect.arrayContaining([expect.objectContaining({ name: 'shadow-redirect', value: 'happy' })]) - ); - }); - - test('Handles POST success with returned location', async ({ page, clicknav }) => { - await page.goto('/shadowed/post-success-redirect'); - await clicknav('button'); - expect(await page.textContent('h1')).toBe('POST was successful'); - }); - - test('Renders error page for 4xx and 5xx responses from GET', async ({ page, clicknav }) => { - await page.goto('/shadowed'); - await clicknav('[href="/shadowed/error-get"]'); - expect(await page.textContent('h1')).toBe('404'); - }); - - test('Merges bodies for 4xx and 5xx responses from non-GET', async ({ page }) => { - await page.goto('/shadowed'); - const [response] = await Promise.all([page.waitForNavigation(), page.click('#error-post')]); - expect(await page.textContent('h1')).toBe('hello from get / echo: posted data'); - - expect(response?.status()).toBe(400); - expect(await page.textContent('h2')).toBe('status: 400'); - }); - - test('Endpoint receives consistent URL', async ({ baseURL, page, clicknav }) => { - await page.goto('/shadowed/same-render-entry'); - await clicknav('[href="/shadowed/same-render?param1=value1"]'); - expect(await page.textContent('h1')).toBe(`URL: ${baseURL}/shadowed/same-render?param1=value1`); - }); - - test('Works with missing get handler', async ({ page, clicknav }) => { - await page.goto('/shadowed'); - await clicknav('[href="/shadowed/no-get"]'); - expect(await page.textContent('h1')).toBe('hello'); - }); - - test('Invalidates shadow data when URL changes', async ({ page, clicknav }) => { - await page.goto('/shadowed'); - await clicknav('[href="/shadowed/dynamic/foo"]'); - expect(await page.textContent('h1')).toBe('slug: foo'); - - await clicknav('[href="/shadowed/dynamic/bar"]'); - expect(await page.textContent('h1')).toBe('slug: bar'); - - await page.goto('/shadowed/dynamic/foo'); - expect(await page.textContent('h1')).toBe('slug: foo'); - await clicknav('[href="/shadowed/dynamic/bar"]'); - expect(await page.textContent('h1')).toBe('slug: bar'); - }); - - test('Shadow redirect', async ({ page, clicknav }) => { - await page.goto('/shadowed/redirect'); - await clicknav('[href="/shadowed/redirect/a"]'); - expect(await page.textContent('h1')).toBe('done'); - }); - - test('Endpoint without GET', async ({ page, clicknav, baseURL, javaScriptEnabled }) => { - await page.goto('/shadowed'); - - /** @type {string[]} */ - const requests = []; - page.on('request', (r) => requests.push(r.url())); - - await clicknav('[href="/shadowed/missing-get"]'); - - expect(await page.textContent('h1')).toBe('post without get'); - - // check that the router didn't fall back to the server - if (javaScriptEnabled) { - expect(requests).not.toContain(`${baseURL}/shadowed/missing-get`); - } - }); - - test('Parent data is present', async ({ page, clicknav }) => { - await page.goto('/shadowed/parent'); - await expect(page.locator('h2')).toHaveText( - 'Layout data: {"foo":{"bar":"Custom layout"},"layout":"layout"}' - ); - await expect(page.locator('p')).toHaveText( - 'Page data: {"foo":{"bar":"Custom layout"},"layout":"layout","page":"page","data":{"rootlayout":"rootlayout","layout":"layout"}}' - ); - - await clicknav('[href="/shadowed/parent?test"]'); - await expect(page.locator('h2')).toHaveText( - 'Layout data: {"foo":{"bar":"Custom layout"},"layout":"layout"}' - ); - await expect(page.locator('p')).toHaveText( - 'Page data: {"foo":{"bar":"Custom layout"},"layout":"layout","page":"page","data":{"rootlayout":"rootlayout","layout":"layout"}}' - ); - - await clicknav('[href="/shadowed/parent/sub"]'); - await expect(page.locator('h2')).toHaveText( - 'Layout data: {"foo":{"bar":"Custom layout"},"layout":"layout"}' - ); - await expect(page.locator('p')).toHaveText( - 'Page data: {"foo":{"bar":"Custom layout"},"layout":"layout","sub":"sub","data":{"rootlayout":"rootlayout","layout":"layout"}}' - ); - }); - - if (process.env.DEV) { - test('Data must be serializable', async ({ page, clicknav }) => { - await page.goto('/shadowed'); - await clicknav('[href="/shadowed/serialization"]'); - - expect(await page.textContent('h1')).toBe('500'); - expect(await page.textContent('#message')).toBe( - 'This is your custom error page saying: "Data returned from `load` while rendering /shadowed/serialization is not serializable: Cannot stringify arbitrary non-POJOs (data.nope)"' - ); - }); - } -}); - test.describe('Encoded paths', () => { test('visits a route with non-ASCII character', async ({ page, clicknav }) => { await page.goto('/encoded'); @@ -398,258 +191,6 @@ test.describe('$env', () => { }); }); -test.describe('Errors', () => { - if (process.env.DEV) { - // TODO these probably shouldn't have the full render treatment, - // given that they will never be user-visible in prod - test('server-side errors', async ({ page }) => { - await page.goto('/errors/serverside'); - - expect(await page.textContent('footer')).toBe('Custom layout'); - expect(await page.textContent('#message')).toBe( - 'This is your custom error page saying: "Crashing now"' - ); - }); - - test('server-side module context errors', async ({ page }) => { - test.fixme(); - - await page.goto('/errors/module-scope-server'); - - expect(await page.textContent('footer')).toBe('Custom layout'); - expect(await page.textContent('#message')).toBe( - 'This is your custom error page saying: "Crashing now"' - ); - }); - - test('errors on invalid load function response', async ({ page, app, javaScriptEnabled }) => { - if (javaScriptEnabled) { - await page.goto('/'); - await app.goto('/errors/invalid-load-response'); - } else { - await page.goto('/errors/invalid-load-response'); - } - - expect(await page.textContent('footer')).toBe('Custom layout'); - expect(await page.textContent('#message')).toBe( - 'This is your custom error page saying: "a load function related to route \'/errors/invalid-load-response\' returned an array, but must return a plain object at the top level (i.e. `return {...}`)"' - ); - }); - - test('errors on invalid server load function response', async ({ - page, - app, - javaScriptEnabled - }) => { - if (javaScriptEnabled) { - await page.goto('/'); - await app.goto('/errors/invalid-server-load-response'); - } else { - await page.goto('/errors/invalid-server-load-response'); - } - - expect(await page.textContent('footer')).toBe('Custom layout'); - expect(await page.textContent('#message')).toBe( - 'This is your custom error page saying: "a load function related to route \'/errors/invalid-server-load-response\' returned an array, but must return a plain object at the top level (i.e. `return {...}`)"' - ); - }); - } - - test('server-side load errors', async ({ page }) => { - await page.goto('/errors/load-server'); - - expect(await page.textContent('footer')).toBe('Custom layout'); - expect(await page.textContent('#message')).toBe( - 'This is your custom error page saying: "Crashing now"' - ); - - expect( - await page.evaluate(() => { - const el = document.querySelector('h1'); - return el && getComputedStyle(el).color; - }) - ).toBe('rgb(255, 0, 0)'); - }); - - test('404', async ({ page }) => { - const response = await page.goto('/why/would/anyone/fetch/this/url'); - - expect(await page.textContent('footer')).toBe('Custom layout'); - expect(await page.textContent('#message')).toBe( - 'This is your custom error page saying: "Not found: /why/would/anyone/fetch/this/url"' - ); - expect(/** @type {Response} */ (response).status()).toBe(404); - }); - - test('server-side error from load() is a string', async ({ page }) => { - const response = await page.goto('/errors/load-error-string-server'); - - expect(await page.textContent('footer')).toBe('Custom layout'); - expect(await page.textContent('#message')).toBe( - 'This is your custom error page saying: "Not found"' - ); - expect(/** @type {Response} */ (response).status()).toBe(555); - }); - - test('server-side error from load() is an Error', async ({ page }) => { - const response = await page.goto('/errors/load-error-server'); - - expect(await page.textContent('footer')).toBe('Custom layout'); - expect(await page.textContent('#message')).toBe( - 'This is your custom error page saying: "Not found"' - ); - expect(/** @type {Response} */ (response).status()).toBe(555); - }); - - test('error in endpoint', async ({ page, read_errors }) => { - const res = await page.goto('/errors/endpoint'); - - // should include stack trace - const lines = read_errors('/errors/endpoint.json').stack.split('\n'); - expect(lines[0]).toMatch('nope'); - - if (process.env.DEV) { - expect(lines[1]).toMatch('endpoint.json'); - } - - expect(res && res.status()).toBe(500); - expect(await page.textContent('#message')).toBe('This is your custom error page saying: "500"'); - }); - - test('error in shadow endpoint', async ({ page, read_errors }) => { - const res = await page.goto('/errors/endpoint-shadow'); - - // should include stack trace - const lines = read_errors('/errors/endpoint-shadow').stack.split('\n'); - expect(lines[0]).toMatch('nope'); - - if (process.env.DEV) { - expect(lines[1]).toMatch('+page.server.js:3:8'); - } - - expect(res && res.status()).toBe(500); - expect(await page.textContent('#message')).toBe( - 'This is your custom error page saying: "nope"' - ); - }); - - test('not ok response from shadow endpoint', async ({ page, read_errors }) => { - const res = await page.goto('/errors/endpoint-shadow-not-ok'); - - expect(read_errors('/errors/endpoint-shadow-not-ok')).toBeUndefined(); - - expect(res && res.status()).toBe(555); - expect(await page.textContent('#message')).toBe( - 'This is your custom error page saying: "Error: 555"' - ); - }); - - test('prerendering a page with a mutative page endpoint results in a catchable error', async ({ - page - }) => { - await page.goto('/prerendering/mutative-endpoint'); - expect(await page.textContent('h1')).toBe('500'); - - expect(await page.textContent('#message')).toBe( - 'This is your custom error page saying: "Cannot prerender pages with actions"' - ); - }); - - test('page endpoint GET thrown error message is preserved', async ({ - page, - clicknav, - read_errors - }) => { - await page.goto('/errors/page-endpoint'); - await clicknav('#get-implicit'); - - expect(await page.textContent('pre')).toBe( - JSON.stringify({ status: 500, message: 'oops' }, null, ' ') - ); - - const { status, name, message, stack, fancy } = read_errors( - '/errors/page-endpoint/get-implicit' - ); - expect(status).toBe(undefined); - expect(name).toBe('FancyError'); - expect(message).toBe('oops'); - expect(fancy).toBe(true); - if (process.env.DEV) { - const lines = stack.split('\n'); - expect(lines[1]).toContain('+page.server.js:4:8'); - } - }); - - test('page endpoint GET HttpError message is preserved', async ({ - page, - clicknav, - read_errors - }) => { - await page.goto('/errors/page-endpoint'); - await clicknav('#get-explicit'); - - expect(await page.textContent('pre')).toBe( - JSON.stringify({ status: 400, message: 'oops' }, null, ' ') - ); - - const error = read_errors('/errors/page-endpoint/get-explicit'); - expect(error).toBe(undefined); - }); - - test('page endpoint POST unexpected error message is preserved', async ({ - page, - clicknav, - read_errors - }) => { - // The case where we're submitting a POST request via a form. - // It should show the __error template with our message. - await page.goto('/errors/page-endpoint'); - await clicknav('#post-implicit'); - - expect(await page.textContent('pre')).toBe( - JSON.stringify({ status: 500, message: 'oops' }, null, ' ') - ); - - const { status, name, message, stack, fancy } = read_errors( - '/errors/page-endpoint/post-implicit' - ); - - expect(status).toBe(undefined); - expect(name).toBe('FancyError'); - expect(message).toBe('oops'); - expect(fancy).toBe(true); - if (process.env.DEV) { - const lines = stack.split('\n'); - expect(lines[1]).toContain('+page.server.js:6:9'); - } - }); - - test('page endpoint POST HttpError error message is preserved', async ({ - page, - clicknav, - read_errors - }) => { - // The case where we're submitting a POST request via a form. - // It should show the __error template with our message. - await page.goto('/errors/page-endpoint'); - await clicknav('#post-explicit'); - - expect(await page.textContent('pre')).toBe( - JSON.stringify({ status: 400, message: 'oops' }, null, ' ') - ); - - const error = read_errors('/errors/page-endpoint/post-explicit'); - expect(error).toBe(undefined); - }); -}); - -test.describe('Headers', () => { - test('allows headers to be sent as a Headers class instead of a POJO', async ({ page }) => { - await page.goto('/headers/class'); - expect(await page.innerHTML('p')).toBe('bar'); - }); -}); - test.describe('Load', () => { test('fetch in root index.svelte works', async ({ page }) => { await page.goto('/'); @@ -1250,480 +791,6 @@ test.describe('searchParams', () => { }); }); -test.describe('Redirects', () => { - test('redirect', async ({ baseURL, page, clicknav }) => { - await page.goto('/redirect'); - - await clicknav('[href="/redirect/a"]'); - - await page.waitForURL('/redirect/c'); - expect(await page.textContent('h1')).toBe('c'); - expect(page.url()).toBe(`${baseURL}/redirect/c`); - - await page.goBack(); - expect(page.url()).toBe(`${baseURL}/redirect`); - }); - - test('prevents redirect loops', async ({ baseURL, page, javaScriptEnabled, browserName }) => { - await page.goto('/redirect'); - - await page.locator('[href="/redirect/loopy/a"]').click(); - - if (javaScriptEnabled) { - await page.waitForSelector('#message'); - expect(page.url()).toBe(`${baseURL}/redirect/loopy/a`); - expect(await page.textContent('h1')).toBe('500'); - expect(await page.textContent('#message')).toBe( - 'This is your custom error page saying: "Redirect loop"' - ); - } else { - // there's not a lot we can do to handle server-side redirect loops - if (browserName === 'chromium') { - expect(page.url()).toBe('chrome-error://chromewebdata/'); - } else { - expect(page.url()).toBe(`${baseURL}/redirect`); - } - } - }); - - test('errors on missing status', async ({ - baseURL, - page, - clicknav, - javaScriptEnabled, - read_errors - }) => { - await page.goto('/redirect'); - - await clicknav('[href="/redirect/missing-status/a"]'); - - const message = process.env.DEV || !javaScriptEnabled ? 'Invalid status code' : 'Redirect loop'; - - expect(page.url()).toBe(`${baseURL}/redirect/missing-status/a`); - expect(await page.textContent('h1')).toBe('500'); - expect(await page.textContent('#message')).toBe( - `This is your custom error page saying: "${message}"` - ); - - if (!javaScriptEnabled) { - // handleError is not invoked for client-side navigation - const lines = read_errors('/redirect/missing-status/a').stack.split('\n'); - expect(lines[0]).toBe(`Error: ${message}`); - } - }); - - test('errors on invalid status', async ({ baseURL, page, clicknav, javaScriptEnabled }) => { - await page.goto('/redirect'); - - await clicknav('[href="/redirect/missing-status/b"]'); - - const message = process.env.DEV || !javaScriptEnabled ? 'Invalid status code' : 'Redirect loop'; - - expect(page.url()).toBe(`${baseURL}/redirect/missing-status/b`); - expect(await page.textContent('h1')).toBe('500'); - expect(await page.textContent('#message')).toBe( - `This is your custom error page saying: "${message}"` - ); - }); - - test('redirect-on-load', async ({ baseURL, page, javaScriptEnabled }) => { - const redirected_to_url = javaScriptEnabled - ? `${baseURL}/redirect-on-load/redirected` - : `${baseURL}/redirect-on-load`; - - await Promise.all([page.waitForResponse(redirected_to_url), page.goto('/redirect-on-load')]); - - expect(page.url()).toBe(redirected_to_url); - - if (javaScriptEnabled) { - expect(await page.textContent('h1')).toBe('Hazaa!'); - } - }); - - test('redirect response in handle hook', async ({ baseURL, clicknav, page }) => { - await page.goto('/redirect'); - - await clicknav('[href="/redirect/in-handle?response"]'); - - await page.waitForURL('/redirect/c'); - expect(await page.textContent('h1')).toBe('c'); - expect(page.url()).toBe(`${baseURL}/redirect/c`); - - await page.goBack(); - expect(page.url()).toBe(`${baseURL}/redirect`); - }); - - test('throw redirect in handle hook', async ({ baseURL, clicknav, page }) => { - await page.goto('/redirect'); - - await clicknav('[href="/redirect/in-handle?throw"]'); - - await page.waitForURL('/redirect/c'); - expect(await page.textContent('h1')).toBe('c'); - expect(page.url()).toBe(`${baseURL}/redirect/c`); - - await page.goBack(); - expect(page.url()).toBe(`${baseURL}/redirect`); - }); -}); - -test.describe('Routing', () => { - test('redirects from /routing/ to /routing', async ({ - baseURL, - page, - clicknav, - app, - javaScriptEnabled - }) => { - await page.goto('/routing/slashes'); - - await clicknav('a[href="/routing/"]'); - expect(page.url()).toBe(`${baseURL}/routing`); - expect(await page.textContent('h1')).toBe('Great success!'); - - if (javaScriptEnabled) { - await page.goto(`${baseURL}/routing/slashes`); - await app.goto('/routing/'); - expect(page.url()).toBe(`${baseURL}/routing`); - expect(await page.textContent('h1')).toBe('Great success!'); - } - }); - - test('redirects from /routing/? to /routing', async ({ - baseURL, - page, - clicknav, - app, - javaScriptEnabled - }) => { - await page.goto('/routing/slashes'); - - await clicknav('a[href="/routing/?"]'); - expect(page.url()).toBe(`${baseURL}/routing`); - expect(await page.textContent('h1')).toBe('Great success!'); - - if (javaScriptEnabled) { - await page.goto(`${baseURL}/routing/slashes`); - await app.goto('/routing/?'); - expect(page.url()).toBe(`${baseURL}/routing`); - expect(await page.textContent('h1')).toBe('Great success!'); - } - }); - - test('redirects from /routing/?foo=bar to /routing?foo=bar', async ({ - baseURL, - page, - clicknav, - app, - javaScriptEnabled - }) => { - await page.goto('/routing/slashes'); - - await clicknav('a[href="/routing/?foo=bar"]'); - expect(page.url()).toBe(`${baseURL}/routing?foo=bar`); - expect(await page.textContent('h1')).toBe('Great success!'); - - if (javaScriptEnabled) { - await page.goto(`${baseURL}/routing/slashes`); - await app.goto('/routing/?foo=bar'); - expect(page.url()).toBe(`${baseURL}/routing?foo=bar`); - expect(await page.textContent('h1')).toBe('Great success!'); - } - }); - - test('serves static route', async ({ page }) => { - await page.goto('/routing/a'); - expect(await page.textContent('h1')).toBe('a'); - }); - - test('serves static route from dir/index.html file', async ({ page }) => { - await page.goto('/routing/b'); - expect(await page.textContent('h1')).toBe('b'); - }); - - test('serves static route under client directory', async ({ baseURL, page }) => { - await page.goto('/routing/client/foo'); - - expect(await page.textContent('h1')).toBe('foo'); - - await page.goto(`${baseURL}/routing/client/bar`); - expect(await page.textContent('h1')).toBe('bar'); - - await page.goto(`${baseURL}/routing/client/bar/b`); - expect(await page.textContent('h1')).toBe('b'); - }); - - test('serves dynamic route', async ({ page }) => { - await page.goto('/routing/test-slug'); - expect(await page.textContent('h1')).toBe('test-slug'); - }); - - test('does not attempt client-side navigation to server routes', async ({ page }) => { - await page.goto('/routing'); - await page.locator('[href="/routing/ambiguous/ok.json"]').click(); - expect(await page.textContent('body')).toBe('ok'); - }); - - test('does not attempt client-side navigation to links with data-sveltekit-reload', async ({ - baseURL, - page, - clicknav - }) => { - await page.goto('/routing'); - - /** @type {string[]} */ - const requests = []; - page.on('request', (r) => requests.push(r.url())); - - await clicknav('[href="/routing/b"]'); - expect(await page.textContent('h1')).toBe('b'); - expect(requests).toContain(`${baseURL}/routing/b`); - }); - - test('allows reserved words as route names', async ({ page }) => { - await page.goto('/routing/const'); - expect(await page.textContent('h1')).toBe('reserved words are okay as routes'); - }); - - test('resets the active element after navigation', async ({ page, clicknav }) => { - await page.goto('/routing'); - await clicknav('[href="/routing/a"]'); - await page.waitForFunction(() => (document.activeElement || {}).nodeName == 'BODY'); - }); - - test('navigates between routes with empty parts', async ({ page, clicknav }) => { - await page.goto('/routing/dirs/foo'); - expect(await page.textContent('h1')).toBe('foo'); - await clicknav('[href="bar"]'); - expect(await page.textContent('h1')).toBe('bar'); - }); - - test('navigates between dynamic routes with same segments', async ({ page, clicknav }) => { - await page.goto('/routing/dirs/bar/xyz'); - expect(await page.textContent('h1')).toBe('A page'); - - await clicknav('[href="/routing/dirs/foo/xyz"]'); - expect(await page.textContent('h1')).toBe('B page'); - }); - - test('invalidates page when a segment is skipped', async ({ page, clicknav }) => { - await page.goto('/routing/skipped/x/1'); - expect(await page.textContent('h1')).toBe('x/1'); - - await clicknav('#goto-y1'); - expect(await page.textContent('h1')).toBe('y/1'); - }); - - test('back button returns to initial route', async ({ page, clicknav }) => { - await page.goto('/routing'); - await clicknav('[href="/routing/a"]'); - - await page.goBack(); - await page.waitForLoadState('networkidle'); - expect(await page.textContent('h1')).toBe('Great success!'); - }); - - test('back button returns to previous route when previous route has been navigated to via hash anchor', async ({ - page, - clicknav - }) => { - await page.goto('/routing/hashes/a'); - - await page.locator('[href="#hash-target"]').click(); - await clicknav('[href="/routing/hashes/b"]'); - - await page.goBack(); - expect(await page.textContent('h1')).toBe('a'); - }); - - test('focus works if page load has hash', async ({ page, browserName }) => { - await page.goto('/routing/hashes/target#p2'); - - await page.keyboard.press(browserName === 'webkit' ? 'Alt+Tab' : 'Tab'); - await page.waitForTimeout(50); // give browser a bit of time to complete the native behavior of the key press - expect( - await page.evaluate( - () => document.activeElement?.textContent || 'ERROR: document.activeElement not set' - ) - ).toBe('next focus element'); - }); - - test('focus works when navigating to a hash on the same page', async ({ page, browserName }) => { - await page.goto('/routing/hashes/target'); - - await page.click('[href="#p2"]'); - await page.keyboard.press(browserName === 'webkit' ? 'Alt+Tab' : 'Tab'); - - expect(await page.evaluate(() => (document.activeElement || {}).textContent)).toBe( - 'next focus element' - ); - }); - - test(':target pseudo-selector works when navigating to a hash on the same page', async ({ - page - }) => { - await page.goto('/routing/hashes/target#p1'); - - expect( - await page.evaluate(() => { - const el = document.getElementById('p1'); - return el && getComputedStyle(el).color; - }) - ).toBe('rgb(255, 0, 0)'); - await page.click('[href="#p2"]'); - expect( - await page.evaluate(() => { - const el = document.getElementById('p2'); - return el && getComputedStyle(el).color; - }) - ).toBe('rgb(255, 0, 0)'); - }); - - test('last parameter in a segment wins in cases of ambiguity', async ({ page, clicknav }) => { - await page.goto('/routing/split-params'); - await clicknav('[href="/routing/split-params/x-y-z"]'); - expect(await page.textContent('h1')).toBe('x'); - expect(await page.textContent('h2')).toBe('y-z'); - }); - - test('ignores navigation to URLs the app does not own', async ({ page, start_server }) => { - const { port } = await start_server((req, res) => res.end('ok')); - - await page.goto(`/routing?port=${port}`); - await Promise.all([ - page.click(`[href="http://localhost:${port}"]`), - // assert that the app can visit a URL not owned by the app without crashing - page.waitForURL(`http://localhost:${port}/`) - ]); - }); - - test('navigates to ...rest', async ({ page, clicknav }) => { - await page.goto('/routing/rest/abc/xyz'); - - expect(await page.textContent('h1')).toBe('abc/xyz'); - - await clicknav('[href="/routing/rest/xyz/abc/def/ghi"]'); - expect(await page.textContent('h1')).toBe('xyz/abc/def/ghi'); - expect(await page.textContent('h2')).toBe('xyz/abc/def/ghi'); - - await clicknav('[href="/routing/rest/xyz/abc/def"]'); - expect(await page.textContent('h1')).toBe('xyz/abc/def'); - expect(await page.textContent('h2')).toBe('xyz/abc/def'); - - await clicknav('[href="/routing/rest/xyz/abc"]'); - expect(await page.textContent('h1')).toBe('xyz/abc'); - expect(await page.textContent('h2')).toBe('xyz/abc'); - - await clicknav('[href="/routing/rest"]'); - expect(await page.textContent('h1')).toBe(''); - expect(await page.textContent('h2')).toBe(''); - - await clicknav('[href="/routing/rest/xyz/abc/deep"]'); - expect(await page.textContent('h1')).toBe('xyz/abc'); - expect(await page.textContent('h2')).toBe('xyz/abc'); - - await page.locator('[href="/routing/rest/xyz/abc/qwe/deep.json"]').click(); - expect(await page.textContent('body')).toBe('xyz/abc/qwe'); - }); - - test('rest parameters do not swallow characters', async ({ page, clicknav }) => { - await page.goto('/routing/rest/non-greedy'); - - await clicknav('[href="/routing/rest/non-greedy/foo/one/two"]'); - expect(await page.textContent('h1')).toBe('non-greedy'); - expect(await page.textContent('h2')).toBe('{"rest":"one/two"}'); - - await clicknav('[href="/routing/rest/non-greedy/food/one/two"]'); - expect(await page.textContent('h1')).not.toBe('non-greedy'); - - await page.goBack(); - - await clicknav('[href="/routing/rest/non-greedy/one-bar/two/three"]'); - expect(await page.textContent('h1')).toBe('non-greedy'); - expect(await page.textContent('h2')).toBe('{"dynamic":"one","rest":"two/three"}'); - - await clicknav('[href="/routing/rest/non-greedy/one-bard/two/three"]'); - expect(await page.textContent('h1')).not.toBe('non-greedy'); - }); - - test('reloads when navigating between ...rest pages', async ({ page, clicknav }) => { - await page.goto('/routing/rest/path/one'); - expect(await page.textContent('h1')).toBe('path: /routing/rest/path/one'); - - await clicknav('[href="/routing/rest/path/two"]'); - expect(await page.textContent('h1')).toBe('path: /routing/rest/path/two'); - - await clicknav('[href="/routing/rest/path/three"]'); - expect(await page.textContent('h1')).toBe('path: /routing/rest/path/three'); - }); - - test('allows rest routes to have prefixes and suffixes', async ({ page }) => { - await page.goto('/routing/rest/complex/prefix-one/two/three'); - expect(await page.textContent('h1')).toBe('parts: one/two/three'); - }); - - test('links to unmatched routes result in a full page navigation, not a 404', async ({ - page, - clicknav - }) => { - await page.goto('/routing'); - await clicknav('[href="/static.json"]'); - expect(await page.textContent('body')).toBe('"static file"\n'); - }); - - test('navigation is cancelled upon subsequent navigation', async ({ - baseURL, - page, - clicknav - }) => { - await page.goto('/routing/cancellation'); - await page.locator('[href="/routing/cancellation/a"]').click(); - await clicknav('[href="/routing/cancellation/b"]'); - - expect(await page.url()).toBe(`${baseURL}/routing/cancellation/b`); - - await page.evaluate('window.fulfil_navigation && window.fulfil_navigation()'); - expect(await page.url()).toBe(`${baseURL}/routing/cancellation/b`); - }); - - test('Relative paths are relative to the current URL', async ({ page, clicknav }) => { - await page.goto('/iframes'); - await clicknav('[href="/iframes/nested/parent"]'); - - expect(await page.frameLocator('iframe').locator('h1').textContent()).toBe( - 'Hello from the child' - ); - }); - - test('exposes page.route.id', async ({ page, clicknav }) => { - await page.goto('/routing/route-id'); - await clicknav('[href="/routing/route-id/foo"]'); - - expect(await page.textContent('h1')).toBe('route.id in load: /routing/route-id/[x]'); - expect(await page.textContent('h2')).toBe('route.id in store: /routing/route-id/[x]'); - }); - - test('serves a page that clashes with a root directory', async ({ page }) => { - await page.goto('/static'); - expect(await page.textContent('h1')).toBe('hello'); - }); - - test('shows "Not Found" in 404 case', async ({ page }) => { - await page.goto('/404-fallback'); - expect(await page.textContent('h1')).toBe('404'); - expect(await page.textContent('p')).toBe('This is your custom error page saying: "Not Found"'); - }); - - if (process.platform !== 'win32') { - test('Respects symlinks', async ({ page, clicknav }) => { - await page.goto('/routing'); - await clicknav('[href="/routing/symlink-from"]'); - - expect(await page.textContent('h1')).toBe('symlinked'); - }); - } -}); - test.describe('Matchers', () => { test('Matches parameters', async ({ page, clicknav }) => { await page.goto('/routing/matched'); @@ -1742,60 +809,6 @@ test.describe('Matchers', () => { }); }); -test.describe('XSS', () => { - test('replaces %sveltekit.xxx% tags safely', async ({ page }) => { - await page.goto('/unsafe-replacement'); - - const content = await page.textContent('body'); - expect(content).toMatch('$& $&'); - }); - - test('escapes inline data', async ({ page, javaScriptEnabled }) => { - await page.goto('/xss'); - - expect(await page.textContent('h1')).toBe( - 'user.name is ' - ); - - if (!javaScriptEnabled) { - // @ts-expect-error - check global injected variable - expect(await page.evaluate(() => window.pwned)).toBeUndefined(); - } - }); - - const uri_xss_payload = ''; - const uri_xss_payload_encoded = encodeURIComponent(uri_xss_payload); - - test('no xss via dynamic route path', async ({ page }) => { - await page.goto(`/xss/${uri_xss_payload_encoded}`); - - expect(await page.textContent('h1')).toBe(uri_xss_payload); - - // @ts-expect-error - check global injected variable - expect(await page.evaluate(() => window.pwned)).toBeUndefined(); - }); - - test('no xss via query param', async ({ page }) => { - await page.goto(`/xss/query?key=${uri_xss_payload_encoded}`); - - expect(await page.textContent('#one')).toBe(JSON.stringify({ key: [uri_xss_payload] })); - expect(await page.textContent('#two')).toBe(JSON.stringify({ key: [uri_xss_payload] })); - - // @ts-expect-error - check global injected variable - expect(await page.evaluate(() => window.pwned)).toBeUndefined(); - }); - - test('no xss via shadow endpoint', async ({ page }) => { - await page.goto('/xss/shadow'); - - // @ts-expect-error - check global injected variable - expect(await page.evaluate(() => window.pwned)).toBeUndefined(); - expect(await page.textContent('h1')).toBe( - 'user.name is ' - ); - }); -}); - test.describe('Actions', () => { test('Error props are returned', async ({ page, javaScriptEnabled }) => { await page.goto('/actions/form-errors'); @@ -2003,8 +1016,7 @@ test.describe('Actions', () => { test.describe.serial('Cookies API', () => { // there's a problem running these tests in the CI with webkit, // since AFAICT the browser is using http://localhost and webkit won't - // set a `Secure` cookie on that. So we bail... - test.skip(({ browserName }) => browserName === 'webkit'); + // set a `Secure` cookie on that. So we don't run these cross-browser test('sanity check for cookies', async ({ page }) => { await page.goto('/cookies'); diff --git a/packages/kit/test/apps/writes/package.json b/packages/kit/test/apps/writes/package.json index a4879d8c8f45..53b981b906ce 100644 --- a/packages/kit/test/apps/writes/package.json +++ b/packages/kit/test/apps/writes/package.json @@ -8,8 +8,8 @@ "preview": "vite preview", "check": "svelte-kit sync && tsc && svelte-check", "test": "pnpm test:dev && pnpm test:build", - "test:dev": "rimraf test/errors.json && cross-env DEV=true playwright test", - "test:build": "rimraf test/errors.json && playwright test" + "test:dev": "cross-env DEV=true playwright test", + "test:build": "playwright test" }, "devDependencies": { "@sveltejs/kit": "workspace:^", diff --git a/turbo.json b/turbo.json index fa32ca6b676e..044145cfdc64 100644 --- a/turbo.json +++ b/turbo.json @@ -4,22 +4,23 @@ "kit.svelte.dev#build": { "dependsOn": ["^build"], "inputs": ["src/**", "../../packages/kit/docs/**", "../../documentation/**"], - "outputs": [".vercel_build_output/**", ".vercel/output/**"], + "outputs": [".vercel/output/**"], "outputMode": "new-only", - "env": ["VERCEL", "ENABLE_VC_BUILD"] + "env": ["VERCEL"] }, - "build": { + "create-svelte#build": { "dependsOn": ["^build"], "inputs": ["src/**", "scripts/**", "shared/**", "templates/**"], - "outputs": [ - "files/**", - "dist/**", - ".svelte-kit/**", - ".vercel_build_output/**", - ".vercel/output/**" - ], + "outputs": ["dist/**"], + "outputMode": "new-only", + "env": ["VERCEL"] + }, + "build": { + "dependsOn": ["^build"], + "inputs": ["src/**"], + "outputs": ["files/**", "dist/**", ".svelte-kit/**", ".vercel/output/**"], "outputMode": "new-only", - "env": ["VERCEL", "ENABLE_VC_BUILD"] + "env": ["VERCEL"] }, "check": { "inputs": [ @@ -43,6 +44,13 @@ }, "test": { "dependsOn": ["^build"], + "inputs": ["test/**"], + "outputs": ["coverage/", "test-results/**"], + "outputMode": "new-only", + "env": ["CI", "TURBO_CACHE_KEY"] + }, + "test:cross-browser": { + "inputs": ["src/**", "scripts/**", "shared/**", "templates/**", "test/**"], "outputs": ["coverage/", "test-results/**"], "outputMode": "new-only", "env": ["CI", "TURBO_CACHE_KEY"]