diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index ac393da..e094a1e 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -34,7 +34,7 @@ jobs: - run: pnpm install - name: Install Playwright (chromium) run: pnpm exec playwright install --with-deps chromium - - name: Run examples smoke tests + - name: Run e2e tests run: pnpm exec playwright test --shard=${{ matrix.shard }}/4 timeout-minutes: 30 - uses: actions/upload-artifact@v4 diff --git a/e2e/create-pages-basic.spec.ts b/e2e/create-pages-basic.spec.ts new file mode 100644 index 0000000..9527c18 --- /dev/null +++ b/e2e/create-pages-basic.spec.ts @@ -0,0 +1,134 @@ +import { expect } from '@playwright/test'; +import { prepareExample, test, waitForHydration } from './utils.js'; + +const startApp = prepareExample('create-pages/basic'); + +test.describe('create-pages/basic', () => { + let port: number; + let stopApp: () => Promise; + const url = (path: string) => `http://localhost:${port}${path}`; + + test.beforeAll(async ({ mode }) => { + ({ port, stopApp } = await startApp(mode)); + }); + + test.afterAll(async () => { + await stopApp(); + }); + + test('navigates between static and dynamic pages', async ({ page }) => { + await page.goto(url('/')); + await waitForHydration(page); + await expect(page).toHaveTitle('Waku'); + await expect(page.getByText('This is the home page.')).toBeVisible(); + await page.locator('a[href="/foo"]').click(); + await expect( + page.getByRole('heading', { name: 'Foo', exact: true }), + ).toBeVisible(); + await expect(page.getByText('path: /foo')).toBeVisible(); + await page.getByRole('button', { name: 'Increment' }).click(); + await expect(page.getByText('Count: 1')).toBeVisible(); + await page.getByRole('link', { name: 'Baz', exact: true }).click(); + await expect( + page.getByRole('heading', { name: 'Dynamic: Baz' }), + ).toBeVisible(); + await expect(page).toHaveURL(url('/baz')); + }); + + test('renders nested layouts and static paths', async ({ page }) => { + await page.goto(url('/nested/qux')); + await expect( + page.getByRole('heading', { name: 'Nested Layout' }), + ).toBeVisible(); + await expect(page.getByRole('heading', { name: 'Qux' })).toBeVisible(); + await page.goto(url('/nested/foo')); + await expect( + page.getByRole('heading', { name: 'Deeply Nested Layout' }), + ).toBeVisible(); + await expect( + page.getByRole('heading', { name: 'Static: foo' }), + ).toBeVisible(); + await page.goto(url('/nested/xyz')); + await expect( + page.getByRole('heading', { name: 'Dynamic: xyz' }), + ).toBeVisible(); + }); + + test('renders a new timestamp for each visit to a dynamic page', async ({ + page, + }) => { + await page.goto(url('/nested/baz')); + const first = await page.locator('p').last().textContent(); + expect(first).toMatch(/^\d{4}-\d{2}-\d{2}T/); + await page.reload(); + await expect(page.locator('p').last()).not.toHaveText(first!); + }); + + test('matches wildcard and catch-all routes', async ({ page }) => { + await page.goto(url('/wild/hello/world')); + await expect( + page.getByRole('heading', { name: 'Slug: hello/world' }), + ).toBeVisible(); + await page.goto(url('/any/a/b')); + await expect( + page.getByRole('heading', { name: 'Catch-all: a/b' }), + ).toBeVisible(); + }); + + test('renders the custom 404 page for an unknown path', async ({ page }) => { + const res = await page.goto(url('/no-such-page')); + expect(res?.status()).toBe(404); + await expect( + page.getByRole('heading', { name: 'Not Found' }), + ).toBeVisible(); + await expect(page.locator('a[href="/foo"]')).toBeVisible(); + }); + + test('renders a static slice once and a dynamic slice per request', async ({ + page, + }) => { + await page.goto(url('/slice-page')); + const staticSlice = page.getByRole('heading', { name: /^Static Slice / }); + await expect(staticSlice).toBeVisible(); + await expect( + page.getByRole('heading', { name: /^Dynamic Slice Component / }), + ).toBeVisible(); + const text = await staticSlice.textContent(); + await page.reload(); + await expect(staticSlice).toHaveText(text!); + }); + + test('jumps to a random page from a server function', async ({ page }) => { + await page.goto(url('/foo')); + await waitForHydration(page); + await page.getByRole('button', { name: 'Jump to random page' }).click(); + await expect(page).not.toHaveURL(url('/foo')); + const headings: Record = { + '/bar': 'Bar', + '/baz': 'Dynamic: Baz', + '/nested/qux': 'Qux', + '/nested/aaa': 'Dynamic: aaa', + '/nested/bbb': 'Dynamic: bbb', + }; + const heading = headings[new URL(page.url()).pathname]; + expect(heading).toBeDefined(); + await expect( + page.getByRole('heading', { name: heading, exact: true }), + ).toBeVisible(); + }); + + test('serves API routes', async ({ request }) => { + expect(await (await request.get(url('/api/hi'))).text()).toBe( + 'hello world!', + ); + expect( + await (await request.post(url('/api/hi'), { data: 'Waku' })).text(), + ).toBe('hello Waku!'); + expect(await (await request.get(url('/api/hi.txt'))).text()).toBe( + 'hello from a text file!', + ); + const empty = await request.get(url('/api/empty')); + expect(empty.status()).toBe(200); + expect(await empty.text()).toBe(''); + }); +}); diff --git a/e2e/create-pages-react-tweet.spec.ts b/e2e/create-pages-react-tweet.spec.ts new file mode 100644 index 0000000..8a4976d --- /dev/null +++ b/e2e/create-pages-react-tweet.spec.ts @@ -0,0 +1,28 @@ +import { expect } from '@playwright/test'; +import { prepareExample, test } from './utils.js'; + +const startApp = prepareExample('create-pages/react-tweet'); + +test.describe('create-pages/react-tweet', () => { + let port: number; + let stopApp: () => Promise; + + test.beforeAll(async ({ mode }) => { + ({ port, stopApp } = await startApp(mode)); + }); + + test.afterAll(async () => { + await stopApp(); + }); + + // The tweet is fetched from X's API, which may be unreachable, and then + // react-tweet renders its "Tweet not found" card in the same container. + test('renders the embedded tweet card', async ({ page }) => { + await page.goto(`http://localhost:${port}/`); + await expect(page).toHaveTitle('Waku'); + await expect( + page.getByRole('heading', { name: 'Waku', exact: true }), + ).toBeVisible(); + await expect(page.locator('.react-tweet-theme article')).toBeVisible(); + }); +}); diff --git a/e2e/create-pages-view-transitions.spec.ts b/e2e/create-pages-view-transitions.spec.ts new file mode 100644 index 0000000..da97be1 --- /dev/null +++ b/e2e/create-pages-view-transitions.spec.ts @@ -0,0 +1,54 @@ +import { expect } from '@playwright/test'; +import { prepareExample, test, waitForHydration } from './utils.js'; + +const startApp = prepareExample('create-pages/view-transitions'); + +test.describe('create-pages/view-transitions', () => { + let port: number; + let stopApp: () => Promise; + + test.beforeAll(async ({ mode }) => { + ({ port, stopApp } = await startApp(mode)); + }); + + test.afterAll(async () => { + await stopApp(); + }); + + test('runs a view transition on each navigation', async ({ page }) => { + await page.addInitScript(() => { + const startViewTransition = Document.prototype.startViewTransition; + let count = 0; + Object.defineProperty(window, 'viewTransitions', { get: () => count }); + Document.prototype.startViewTransition = function (...args) { + count += 1; + return startViewTransition.apply(this, args); + }; + }); + const viewTransitions = () => + page.evaluate( + () => + (window as unknown as { viewTransitions: number }).viewTransitions, + ); + await page.goto(`http://localhost:${port}/`); + await waitForHydration(page); + await expect( + page.getByRole('heading', { name: 'Waku view transitions' }), + ).toBeVisible(); + await page.getByRole('link', { name: 'About' }).click(); + await expect( + page.getByRole('heading', { name: 'About Waku' }), + ).toBeVisible(); + await expect( + page.getByText( + 'This is the about page with a different background color.', + ), + ).toBeVisible(); + await expect.poll(viewTransitions).toBe(1); + await page.getByRole('link', { name: 'Home' }).click(); + await expect( + page.getByRole('heading', { name: 'Waku view transitions' }), + ).toBeVisible(); + await expect.poll(viewTransitions).toBe(2); + }); +}); diff --git a/e2e/create-pages-weave-render.spec.ts b/e2e/create-pages-weave-render.spec.ts new file mode 100644 index 0000000..e9c5e52 --- /dev/null +++ b/e2e/create-pages-weave-render.spec.ts @@ -0,0 +1,54 @@ +import { expect } from '@playwright/test'; +import { prepareExample, test, waitForHydration } from './utils.js'; + +const startApp = prepareExample('create-pages/weave-render'); + +test.describe('create-pages/weave-render', () => { + let port: number; + let stopApp: () => Promise; + const url = (path: string) => `http://localhost:${port}${path}`; + + test.beforeAll(async ({ mode }) => { + ({ port, stopApp } = await startApp(mode)); + }); + + test.afterAll(async () => { + await stopApp(); + }); + + test('keeps the static home layout across pages', async ({ page }) => { + await page.goto(url('/')); + await waitForHydration(page); + const renderTime = page.getByText(/^Last render time: /); + const text = await renderTime.textContent(); + await expect( + page.getByRole('heading', { name: 'Home', exact: true }), + ).toBeVisible(); + await page.locator('a[href="/foo"]').first().click(); + await expect( + page.getByRole('heading', { name: 'FOO layout' }), + ).toBeVisible(); + await expect( + page.getByRole('heading', { name: 'Foo', exact: true }), + ).toBeVisible(); + await expect(renderTime).toHaveText(text!); + await page.locator('a[href="/bar"]').click(); + await expect( + page.getByText('This Layout is expected to be static'), + ).toBeVisible(); + await expect( + page.getByRole('heading', { name: 'Bar', exact: true }), + ).toBeVisible(); + await expect(renderTime).toHaveText(text!); + await page.reload(); + await expect(renderTime).toHaveText(text!); + }); + + test('runs the client component in a dynamic page', async ({ page }) => { + await page.goto(url('/foo')); + await waitForHydration(page); + await expect(page.getByText('path: /foo')).toBeVisible(); + await page.getByRole('button', { name: 'Increment' }).click(); + await expect(page.getByText('Count: 1')).toBeVisible(); + }); +}); diff --git a/e2e/define-router-basic.spec.ts b/e2e/define-router-basic.spec.ts new file mode 100644 index 0000000..8060f87 --- /dev/null +++ b/e2e/define-router-basic.spec.ts @@ -0,0 +1,68 @@ +import { expect } from '@playwright/test'; +import { prepareExample, test, waitForHydration } from './utils.js'; + +const startApp = prepareExample('define-router/basic'); + +test.describe('define-router/basic', () => { + let port: number; + let stopApp: () => Promise; + const url = (path: string) => `http://localhost:${port}${path}`; + + test.beforeAll(async ({ mode }) => { + ({ port, stopApp } = await startApp(mode)); + }); + + test.afterAll(async () => { + await stopApp(); + }); + + test('navigates between the static routes', async ({ page }) => { + await page.goto(url('/')); + await waitForHydration(page); + await expect(page).toHaveTitle('Waku'); + await expect(page.getByText('This is the home page.')).toBeVisible(); + await page.locator('a[href="/foo"]').click(); + await expect( + page.getByRole('heading', { name: 'Foo', exact: true }), + ).toBeVisible(); + await page.getByRole('button', { name: 'Increment' }).click(); + await expect(page.getByText('Count: 1')).toBeVisible(); + await page.getByRole('link', { name: 'Bar', exact: true }).click(); + await expect( + page.getByRole('heading', { name: 'Bar', exact: true }), + ).toBeVisible(); + await page.getByRole('link', { name: 'Nested / Baz' }).click(); + await expect( + page.getByRole('heading', { name: 'Baz', exact: true }), + ).toBeVisible(); + }); + + test('renders the dynamic route for each visited path', async ({ page }) => { + await page.goto(url('/dynamic/foo')); + await waitForHydration(page); + await expect( + page.getByRole('heading', { name: '/dynamic/foo' }), + ).toBeVisible(); + await page.getByRole('link', { name: 'Dynamic / bar' }).click(); + await expect( + page.getByRole('heading', { name: '/dynamic/bar' }), + ).toBeVisible(); + await page.getByRole('link', { name: 'Dynamic / foo' }).click(); + await expect(page).toHaveURL(url('/dynamic/foo')); + await expect( + page.getByRole('heading', { name: '/dynamic/foo' }), + ).toBeVisible(); + }); + + test('serves API routes', async ({ request }) => { + expect(await (await request.get(url('/api/hi'))).text()).toBe( + 'hello world!', + ); + expect(await (await request.get(url('/api/hi.txt'))).text()).toBe( + 'hello from a text file!\n', + ); + const empty = await request.get(url('/api/empty')); + expect(empty.status()).toBe(200); + expect(await empty.text()).toBe(''); + }); +}); diff --git a/e2e/examples-smoke.spec.ts b/e2e/examples-smoke.spec.ts deleted file mode 100644 index 29231dd..0000000 --- a/e2e/examples-smoke.spec.ts +++ /dev/null @@ -1,108 +0,0 @@ -/** - * Smoke tests for all examples. - * - * Boots every example in both DEV and PRD modes and checks that the home page - * renders with a "Waku" title, or the one listed in `expectedTitles`. Examples - * are discovered by scanning the API group directories at the repo root. - */ -import type { ChildProcess } from 'node:child_process'; -import { exec } from 'node:child_process'; -import { readdirSync, rmSync } from 'node:fs'; -import { join } from 'node:path'; -import { fileURLToPath } from 'node:url'; -import { promisify } from 'node:util'; -import { info } from '@actions/core'; -import { expect } from '@playwright/test'; -import { - getAvailablePort, - ignoreErrors, - runShell, - terminate, - test, - waitForPortReady, -} from './utils.js'; - -const execAsync = promisify(exec); - -const groups = ['fs-router', 'create-pages', 'define-router', 'minimal-api']; - -const examples = groups.flatMap((group) => { - const groupDir = fileURLToPath(new URL(`../${group}`, import.meta.url)); - return readdirSync(groupDir, { withFileTypes: true }) - .filter((entry) => entry.isDirectory()) - .map((entry) => ({ - name: `${group}/${entry.name}`, - cwd: join(groupDir, entry.name), - })); -}); - -// The Next.js migrations keep the titles of the apps they were migrated from. -const expectedTitles: Record = { - 'fs-router/nextjs-blog-starter': /^Next\.js Blog Example/, - 'fs-router/nextjs-commerce': /^Acme Store/, - 'fs-router/nextjs-dashboard': /^Acme Dashboard/, - 'fs-router/nextjs-photo-modal': /^NextGram/, -}; - -const modes = ['DEV', 'PRD'] as const; - -test.describe.configure({ mode: 'parallel' }); - -for (const { name, cwd } of examples) { - test.describe.serial(`smoke test on ${name}`, () => { - for (const commandMode of modes) { - // Each example's own scripts are used: `dev` for DEV, `build` + `start` - // for PRD. `start` is `waku start` for most examples and `wrangler dev` - // for the Cloudflare example; both accept `--port`. - const script = commandMode === 'DEV' ? 'dev' : 'start'; - - test.describe(`smoke test in ${commandMode}`, () => { - test.skip(({ mode }) => mode !== commandMode); - - let port: number; - let cp: ChildProcess; - - test.beforeAll(async () => { - if (commandMode === 'PRD') { - rmSync(`${cwd}/dist`, { recursive: true, force: true }); - await execAsync('pnpm --silent run build', { cwd }); - } - port = await getAvailablePort(); - // `--silent` suppresses pnpm's own output (the `$ ` echo and - // lifecycle messages); the example's stdout/stderr still pass through. - cp = runShell(`pnpm --silent run ${script} --port ${port}`, cwd); - cp.stdout?.on('data', (data) => { - if (ignoreErrors.some((re) => re.test(`${data}`))) { - return; - } - info(`stdout: ${data}`); - }); - cp.stderr?.on('data', (data) => { - if (ignoreErrors.some((re) => re.test(`${data}`))) { - return; - } - // Logged, not raised: tools warn on stderr (e.g. wrangler config - // notices) and the title assertion is what determines pass/fail. - info(`stderr: ${data}`); - }); - await waitForPortReady(port); - }); - - test.afterAll(async () => { - if (cp) { - await terminate(cp); - } - }); - - test('check title', async ({ page }) => { - await page.goto(`http://localhost:${port}/`); - // title may not be ready yet - await page.waitForLoadState('load'); - await expect - .poll(() => page.title()) - .toMatch(expectedTitles[name] ?? /^Waku/); - }); - }); - } - }); -} diff --git a/e2e/fs-router-basic-js.spec.ts b/e2e/fs-router-basic-js.spec.ts new file mode 100644 index 0000000..cfde735 --- /dev/null +++ b/e2e/fs-router-basic-js.spec.ts @@ -0,0 +1,62 @@ +import { expect } from '@playwright/test'; +import { prepareExample, test, waitForHydration } from './utils.js'; + +const startApp = prepareExample('fs-router/basic-js'); + +test.describe('fs-router/basic-js', () => { + let port: number; + let stopApp: () => Promise; + const url = (path: string) => `http://localhost:${port}${path}`; + + test.beforeAll(async ({ mode }) => { + ({ port, stopApp } = await startApp(mode)); + }); + + test.afterAll(async () => { + await stopApp(); + }); + + test('navigates between pages with their metadata', async ({ page }) => { + await page.goto(url('/')); + await waitForHydration(page); + await expect(page).toHaveTitle('Waku'); + await expect(page.locator('meta[name="description"]')).toHaveAttribute( + 'content', + 'An internet website!', + ); + const heading = page.getByRole('heading', { name: 'Waku', exact: true }); + await expect(heading).toHaveCSS('font-size', '36px'); + await expect(page.getByText('Hello world!')).toBeVisible(); + await page.getByRole('button', { name: 'Increment' }).click(); + await expect(page.getByText('Count: 1')).toBeVisible(); + await page.getByRole('link', { name: 'About page' }).click(); + await expect( + page.getByRole('heading', { name: 'About Waku' }), + ).toBeVisible(); + await expect(page).toHaveTitle('About'); + await expect(page).toHaveURL(url('/about')); + await page.getByRole('link', { name: 'Waku starter' }).click(); + await expect(page.getByText('Hello world!')).toBeVisible(); + await expect(page).toHaveURL(url('/')); + }); + + test('redirects a trailing slash unless a prerendered file answers first', async ({ + request, + mode, + }) => { + const res = await request.get(url('/no-such-page/'), { maxRedirects: 0 }); + expect(res.status()).toBe(301); + expect(res.headers()['location']).toBe(url('/no-such-page')); + const about = await request.get(url('/about/'), { maxRedirects: 0 }); + expect(about.status()).toBe(mode === 'PRD' ? 200 : 301); + }); + + test('serves files from public', async ({ request }) => { + const icon = await request.get(url('/images/favicon.png')); + expect(icon.headers()['content-type']).toBe('image/png'); + }); + + test('answers an unknown path with 404', async ({ request }) => { + expect((await request.get(url('/no-such-page'))).status()).toBe(404); + }); +}); diff --git a/e2e/fs-router-basic.spec.ts b/e2e/fs-router-basic.spec.ts new file mode 100644 index 0000000..e9f5115 --- /dev/null +++ b/e2e/fs-router-basic.spec.ts @@ -0,0 +1,64 @@ +import { expect } from '@playwright/test'; +import { prepareExample, test, waitForHydration } from './utils.js'; + +const startApp = prepareExample('fs-router/basic'); + +test.describe('fs-router/basic', () => { + let port: number; + let stopApp: () => Promise; + const url = (path: string) => `http://localhost:${port}${path}`; + + test.beforeAll(async ({ mode }) => { + ({ port, stopApp } = await startApp(mode)); + }); + + test.afterAll(async () => { + await stopApp(); + }); + + test('navigates between pages with their metadata', async ({ page }) => { + await page.goto(url('/')); + await waitForHydration(page); + await expect(page).toHaveTitle('Waku'); + await expect(page.locator('meta[name="description"]')).toHaveAttribute( + 'content', + 'An internet website!', + ); + const heading = page.getByRole('heading', { name: 'Waku', exact: true }); + await expect(heading).toHaveCSS('font-size', '36px'); + await expect(page.getByText('Hello world!')).toBeVisible(); + await page.getByRole('button', { name: 'Increment' }).click(); + await expect(page.getByText('Count: 1')).toBeVisible(); + await page.getByRole('link', { name: 'About page' }).click(); + await expect( + page.getByRole('heading', { name: 'About Waku' }), + ).toBeVisible(); + await expect(page).toHaveTitle('About'); + await expect(page).toHaveURL(url('/about')); + await page.getByRole('link', { name: 'Waku starter' }).click(); + await expect(page.getByText('Hello world!')).toBeVisible(); + await expect(page).toHaveURL(url('/')); + }); + + test('redirects a trailing slash unless a prerendered file answers first', async ({ + request, + mode, + }) => { + const res = await request.get(url('/no-such-page/'), { maxRedirects: 0 }); + expect(res.status()).toBe(301); + expect(res.headers()['location']).toBe(url('/no-such-page')); + const about = await request.get(url('/about/'), { maxRedirects: 0 }); + expect(about.status()).toBe(mode === 'PRD' ? 200 : 301); + }); + + test('serves files from public', async ({ request }) => { + const robots = await request.get(url('/robots.txt')); + expect(await robots.text()).toContain('Disallow: /RSC/'); + const icon = await request.get(url('/images/favicon.png')); + expect(icon.headers()['content-type']).toBe('image/png'); + }); + + test('answers an unknown path with 404', async ({ request }) => { + expect((await request.get(url('/no-such-page'))).status()).toBe(404); + }); +}); diff --git a/e2e/fs-router-cloudflare.spec.ts b/e2e/fs-router-cloudflare.spec.ts new file mode 100644 index 0000000..9f6c28b --- /dev/null +++ b/e2e/fs-router-cloudflare.spec.ts @@ -0,0 +1,63 @@ +import { expect } from '@playwright/test'; +import { prepareExample, test, waitForHydration } from './utils.js'; + +const startApp = prepareExample('fs-router/cloudflare', { + env: { CLOUDFLARE_CF_FETCH_ENABLED: 'false', WRANGLER_SEND_METRICS: 'false' }, +}); + +test.describe('fs-router/cloudflare', () => { + let port: number; + let stopApp: () => Promise; + const url = (path: string) => `http://localhost:${port}${path}`; + + test.beforeAll(async ({ mode }) => { + ({ port, stopApp } = await startApp(mode)); + }); + + test.afterAll(async () => { + await stopApp(); + }); + + test('renders a dynamic page with an environment variable', async ({ + page, + }) => { + await page.goto(url('/')); + await waitForHydration(page); + await expect(page).toHaveTitle('Waku'); + await expect(page.getByText('MAX_ITEMS = 10.')).toBeVisible(); + await expect(page.getByText('Hello from server!')).toBeVisible(); + const increment = page.getByRole('button', { name: 'Increment' }); + for (let i = 0; i < 11; i++) { + await increment.click(); + } + await expect(page.getByText('Count: 10')).toBeVisible(); + await page.getByRole('link', { name: 'About page' }).click(); + await expect( + page.getByRole('heading', { name: 'About Waku' }), + ).toBeVisible(); + await expect(page).toHaveTitle('About'); + }); + + test('serves the static 404 page', async ({ request }) => { + const res = await request.get(url('/no-such-page')); + expect(res.status()).toBe(404); + expect(await res.text()).toContain('

Not Found

'); + }); + + test('serves built assets as wrangler.jsonc and _headers configure', async ({ + request, + mode, + }) => { + test.skip(mode === 'DEV', 'Vite serves the assets in DEV'); + const rsc = await request.get(url('/RSC/R/about.txt')); + expect(rsc.headers()['x-robots-tag']).toBe('noindex'); + const html = await (await request.get(url('/about'))).text(); + const asset = await request.get(url(html.match(/\/assets\/[^"]+/)![0])); + expect(asset.headers()['cache-control']).toBe( + 'public, max-age=31536000, immutable', + ); + const slash = await request.get(url('/about/'), { maxRedirects: 0 }); + expect(slash.status()).toBe(307); + expect(slash.headers()['location']).toBe('/about'); + }); +}); diff --git a/e2e/fs-router-css-modules.spec.ts b/e2e/fs-router-css-modules.spec.ts new file mode 100644 index 0000000..1c89ce1 --- /dev/null +++ b/e2e/fs-router-css-modules.spec.ts @@ -0,0 +1,40 @@ +import { expect } from '@playwright/test'; +import { prepareExample, test, waitForHydration } from './utils.js'; + +const startApp = prepareExample('fs-router/css-modules'); + +test.describe('fs-router/css-modules', () => { + let port: number; + let stopApp: () => Promise; + const url = (path: string) => `http://localhost:${port}${path}`; + + test.beforeAll(async ({ mode }) => { + ({ port, stopApp } = await startApp(mode)); + }); + + test.afterAll(async () => { + await stopApp(); + }); + + test('applies CSS modules of server and client components', async ({ + page, + }) => { + await page.goto(url('/')); + await waitForHydration(page); + await expect(page).toHaveTitle('Waku'); + await expect( + page.getByRole('heading', { name: 'Waku', exact: true }), + ).toHaveCSS('font-size', '36px'); + await expect(page.locator('section')).toHaveCSS( + 'border-top-style', + 'dashed', + ); + await page.getByRole('button', { name: 'Increment' }).click(); + await expect(page.getByText('Count: 1')).toBeVisible(); + await page.getByRole('link', { name: 'About page' }).click(); + await expect(page.getByRole('heading', { name: 'About Waku' })).toHaveCSS( + 'font-size', + '36px', + ); + }); +}); diff --git a/e2e/fs-router-data-fetching.spec.ts b/e2e/fs-router-data-fetching.spec.ts new file mode 100644 index 0000000..f5dd992 --- /dev/null +++ b/e2e/fs-router-data-fetching.spec.ts @@ -0,0 +1,65 @@ +import { expect } from '@playwright/test'; +import type { Locator } from '@playwright/test'; +import { prepareExample, test, waitForHydration } from './utils.js'; + +const startApp = prepareExample('fs-router/data-fetching'); + +const hrefsOf = (links: Locator) => + links.evaluateAll((elements) => + elements.map((element) => element.getAttribute('href')!), + ); + +test.describe('fs-router/data-fetching', () => { + let port: number; + let stopApp: () => Promise; + const url = (path: string) => `http://localhost:${port}${path}`; + + test.beforeAll(async ({ mode }) => { + ({ port, stopApp } = await startApp(mode)); + }); + + test.afterAll(async () => { + await stopApp(); + }); + + test('links nine random pokemon to their pages', async ({ + page, + request, + }) => { + await page.goto(url('/')); + await waitForHydration(page); + await expect(page).toHaveTitle('Waku pokemon'); + const links = page.getByRole('listitem').getByRole('link'); + await expect(links).toHaveCount(9); + const hrefs = await hrefsOf(links); + for (const href of hrefs) { + const res = await request.get(url(href)); + expect(res.status()).toBe(200); + expect(await res.text()).toContain('Waku '); + } + await page.getByRole('button', { name: 'reload' }).click(); + await expect.poll(() => hrefsOf(links)).not.toEqual(hrefs); + }); + + test('renders a pokemon page and links back', async ({ page }) => { + await page.goto(url('/bulbasaur')); + await waitForHydration(page); + await expect(page).toHaveTitle('Waku Bulbasaur'); + await expect(page.getByText('フシギダネ')).toBeVisible(); + await page.getByRole('link', { name: 'back' }).click(); + await expect(page).toHaveTitle('Waku pokemon'); + }); + + test('renders pokemon whose slugs have a hyphen or a space', async ({ + page, + }) => { + for (const [path, name] of [ + ['/nidoran-female', 'Nidoran♀'], + ['/nidoran-male', 'Nidoran♂'], + ['/mr.-mime', 'Mr. Mime'], + ]) { + await page.goto(url(path)); + await expect(page).toHaveTitle(`Waku ${name}`); + } + }); +}); diff --git a/e2e/fs-router-features.spec.ts b/e2e/fs-router-features.spec.ts new file mode 100644 index 0000000..a6ecbb5 --- /dev/null +++ b/e2e/fs-router-features.spec.ts @@ -0,0 +1,117 @@ +import { expect } from '@playwright/test'; +import { prepareExample, test, waitForHydration } from './utils.js'; + +const startApp = prepareExample('fs-router/features'); + +test.describe('fs-router/features', () => { + let port: number; + let stopApp: () => Promise<void>; + const url = (path: string) => `http://localhost:${port}${path}`; + + test.beforeAll(async ({ mode }) => { + ({ port, stopApp } = await startApp(mode)); + }); + + test.afterAll(async () => { + await stopApp(); + }); + + test('navigates between pages in shared layouts', async ({ page }) => { + await page.goto(url('/')); + await waitForHydration(page); + await expect(page).toHaveTitle('Waku'); + await expect(page.getByRole('heading', { name: 'Home' })).toBeVisible(); + await page.getByRole('link', { name: /^Foo/ }).click(); + await expect(page.getByRole('heading', { name: 'Foo' })).toBeVisible(); + await page.getByRole('button', { name: 'Increment' }).click(); + await expect(page.getByText('Count: 1')).toBeVisible(); + await page.getByRole('link', { name: /^Nested \/ Baz/ }).click(); + await expect( + page.getByRole('heading', { name: 'Nested Layout' }), + ).toBeVisible(); + await expect( + page.getByRole('heading', { name: 'Nested / baz' }), + ).toBeVisible(); + await page.getByRole('link', { name: /^Nested \/ Qux/ }).click(); + await expect( + page.getByRole('heading', { name: 'Nested / qux' }), + ).toBeVisible(); + await page.getByRole('link', { name: /^Bar/ }).click(); + await page.getByRole('link', { name: 'Go to Home' }).click(); + await expect(page.getByRole('heading', { name: 'Home' })).toBeVisible(); + await expect(page).toHaveURL(url('/')); + }); + + test('shows a pending indicator while navigating', async ({ page }) => { + await page.goto(url('/')); + await waitForHydration(page); + await page.route(/\/RSC\/R\/foo\.txt/, async (route) => { + await new Promise((resolve) => setTimeout(resolve, 1000)); + await route.continue(); + }); + const link = page.getByRole('link', { name: /^Foo/ }); + const pending = link.getByText('Pending...'); + await expect(pending).toHaveCSS('opacity', '0'); + await link.click(); + await expect(pending).toHaveCSS('opacity', '1'); + await expect(page.getByRole('heading', { name: 'Foo' })).toBeVisible(); + await expect(pending).toHaveCSS('opacity', '0'); + }); + + test('prefetches a link on pointer enter', async ({ page }) => { + await page.goto(url('/')); + await waitForHydration(page); + const prefetch = page.waitForRequest(/\/RSC\/R\/bar\.txt/); + await page.getByRole('link', { name: /^Bar/ }).hover(); + await prefetch; + }); + + test('renders the dynamic root per request', async ({ page }) => { + const values = new Set<string | null>(); + for (let i = 0; i < 5; i++) { + await page.goto(url('/')); + values.add(await page.locator('body').getAttribute('data-dynamic-root')); + } + expect([...values][0]).toMatch(/^Random Number \d+$/); + expect(values.size).toBeGreaterThan(1); + }); + + test('renders a static slice once and a dynamic slice per request', async ({ + page, + }) => { + await page.goto(url('/slice-page')); + await expect(page.getByText('Children=Hello from SlicePage')).toBeVisible(); + const one = page.getByRole('heading', { name: /^Slice One / }); + const two = page.getByRole('heading', { name: /^Slice Two / }); + const oneText = await one.textContent(); + const twoText = await two.textContent(); + await expect + .poll(async () => { + await page.reload(); + return two.textContent(); + }) + .not.toBe(twoText); + await expect(one).toHaveText(oneText!); + }); + + test('lists the router configs on the debug page', async ({ page }) => { + await page.goto(url('/debug')); + await expect(page.getByText(/^Server delay complete at /)).toBeVisible(); + await expect(page.locator('pre')).toContainText( + '"sourceFile": "pages/debug.tsx"', + ); + }); + + test('serves API routes', async ({ request }) => { + expect(await (await request.get(url('/hello'))).text()).toBe( + 'Hello from API!', + ); + expect(await (await request.post(url('/hello'))).text()).toBe( + 'Hello from API! /hello', + ); + expect(await (await request.get(url('/users/42'))).json()).toEqual({ + id: '42', + message: 'Hello user 42', + }); + }); +}); diff --git a/e2e/fs-router-form.spec.ts b/e2e/fs-router-form.spec.ts new file mode 100644 index 0000000..7b0c573 --- /dev/null +++ b/e2e/fs-router-form.spec.ts @@ -0,0 +1,72 @@ +import { readFileSync, writeFileSync } from 'node:fs'; +import { expect } from '@playwright/test'; +import { prepareExample, test, waitForHydration } from './utils.js'; + +const startApp = prepareExample('fs-router/form'); +const messageFile = new URL( + '../fs-router/form/private/message.txt', + import.meta.url, +); + +test.describe('fs-router/form', () => { + let port: number; + let stopApp: () => Promise<void>; + let message: string; + const url = (path: string) => `http://localhost:${port}${path}`; + + test.beforeAll(async ({ mode }) => { + message = readFileSync(messageFile, 'utf8'); + ({ port, stopApp } = await startApp(mode)); + }); + + test.afterAll(async () => { + await stopApp(); + writeFileSync(messageFile, message); + }); + + test('appends to the message from the client form', async ({ + page, + mode, + }) => { + await page.goto(url('/')); + await waitForHydration(page); + await expect(page.getByText('Hello from server!')).toBeVisible(); + const form = page.locator('form').filter({ hasText: 'Email:' }); + await form.locator('input[name="name"]').fill(`Alice ${mode}`); + await form.locator('input[name="email"]').fill('alice@example.com'); + await form.getByRole('button', { name: 'Submit' }).click(); + await expect( + form.getByRole('button', { name: 'Pending...' }), + ).toBeDisabled(); + await expect(page.getByText(`Alice ${mode} from server!`)).toBeVisible(); + await expect(form.getByRole('button', { name: 'Submit' })).toBeEnabled(); + }); + + test('submits the server form', async ({ page }) => { + await page.goto(url('/')); + await waitForHydration(page); + await page.getByLabel('Full Name').fill('Carol'); + await page.getByLabel('Age').fill('30'); + await page.getByLabel('Favorite Color').selectOption('green'); + await page.getByLabel('Favorite Hobby').fill('Reading'); + await page.getByLabel('Subscribe to newsletter').check(); + const response = page.waitForResponse( + (res) => res.request().method() === 'POST', + ); + await page.getByRole('button', { name: 'Save Profile' }).click(); + expect((await response).status()).toBe(200); + }); + + test.describe('without JavaScript', () => { + test.use({ javaScriptEnabled: false }); + + test('appends to the message as a page load', async ({ page, mode }) => { + await page.goto(url('/')); + const form = page.locator('form').filter({ hasText: 'Email:' }); + await form.locator('input[name="name"]').fill(`Bob ${mode}`); + await form.locator('input[name="email"]').fill('bob@example.com'); + await form.getByRole('button', { name: 'Submit' }).click(); + await expect(page.getByText(`Bob ${mode} from server!`)).toBeVisible(); + }); + }); +}); diff --git a/e2e/fs-router-jotai.spec.ts b/e2e/fs-router-jotai.spec.ts new file mode 100644 index 0000000..449565f --- /dev/null +++ b/e2e/fs-router-jotai.spec.ts @@ -0,0 +1,40 @@ +import { expect } from '@playwright/test'; +import { prepareExample, test, waitForHydration } from './utils.js'; + +const startApp = prepareExample('fs-router/jotai'); + +test.describe('fs-router/jotai', () => { + let port: number; + let stopApp: () => Promise<void>; + const url = (path: string) => `http://localhost:${port}${path}`; + + test.beforeAll(async ({ mode }) => { + ({ port, stopApp } = await startApp(mode)); + }); + + test.afterAll(async () => { + await stopApp(); + }); + + test('renders server components with a client atom', async ({ page }) => { + await page.goto(url('/')); + await waitForHydration(page); + const clientCount = page.locator('section div', { + hasText: 'Jotai Count:', + }); + const serverCount = page.locator('p', { hasText: 'Jotai Count:' }); + await expect(clientCount).toHaveText('Jotai Count: 1'); + await expect(serverCount).toHaveText('Jotai Count: 1'); + await page.getByRole('button', { name: 'Jotai Increment' }).click(); + await expect(clientCount).toHaveText('Jotai Count: 2'); + await expect(serverCount).toHaveText('Jotai Count: 2'); + await page.getByRole('button', { name: 'Increment', exact: true }).click(); + await expect(page.getByText('Count: 1', { exact: true })).toBeVisible(); + await page.getByRole('link', { name: 'About page' }).click(); + await expect( + page.getByRole('heading', { name: 'About Waku' }), + ).toBeVisible(); + await page.getByRole('link', { name: 'Return home' }).click(); + await expect(serverCount).toHaveText('Jotai Count: 2'); + }); +}); diff --git a/e2e/fs-router-nextjs-blog-starter.spec.ts b/e2e/fs-router-nextjs-blog-starter.spec.ts new file mode 100644 index 0000000..8d4c911 --- /dev/null +++ b/e2e/fs-router-nextjs-blog-starter.spec.ts @@ -0,0 +1,95 @@ +import { expect } from '@playwright/test'; +import { prepareExample, test, waitForHydration } from './utils.js'; + +const startApp = prepareExample('fs-router/nextjs-blog-starter', { + env: { TZ: 'UTC' }, +}); + +test.describe('fs-router/nextjs-blog-starter', () => { + let port: number; + let stopApp: () => Promise<void>; + const url = (path: string) => `http://localhost:${port}${path}`; + + test.beforeAll(async ({ mode }) => { + ({ port, stopApp } = await startApp(mode)); + }); + + test.afterAll(async () => { + await stopApp(); + }); + + test('lists the posts and opens one', async ({ page }) => { + await page.goto(url('/')); + await waitForHydration(page); + await expect(page).toHaveTitle('Next.js Blog Example with Markdown'); + await expect( + page.getByRole('heading', { name: 'Blog.', exact: true }), + ).toHaveCSS('font-size', '100px'); + await expect( + page.getByRole('heading', { level: 3 }).getByRole('link'), + ).toHaveCount(3); + await page + .getByRole('heading', { name: 'Dynamic Routing and Static Generation' }) + .getByRole('link') + .click(); + await expect(page).toHaveURL(url('/posts/dynamic-routing')); + await expect(page.getByRole('heading', { level: 1 })).toHaveText( + 'Dynamic Routing and Static Generation', + ); + await expect(page).toHaveTitle( + 'Dynamic Routing and Static Generation | Next.js Blog Example with Markdown', + ); + await expect( + page.getByRole('heading', { name: 'Lorem Ipsum' }), + ).toBeVisible(); + await page.getByRole('link', { name: 'Blog', exact: true }).click(); + await expect(page).toHaveURL(url('/')); + }); + + test.describe('without JavaScript', () => { + test.use({ javaScriptEnabled: false }); + + test('serves one title and description per post', async ({ page }) => { + await page.goto(url('/posts/hello-world')); + const title = + 'Learn How to Pre-render Pages Using Static Generation with Next.js | Next.js Blog Example with Markdown'; + await expect(page).toHaveTitle(title); + await expect(page.locator('title')).toHaveCount(1); + await expect(page.locator('meta[name="description"]')).toHaveAttribute( + 'content', + 'A statically generated blog example using Next.js and Markdown.', + ); + await expect(page.locator('meta[property="og:title"]')).toHaveAttribute( + 'content', + title, + ); + await expect(page.locator('meta[property="og:image"]')).toHaveAttribute( + 'content', + '/assets/blog/hello-world/cover.jpg', + ); + await expect(page.locator('time')).toHaveText('March 16, 2020'); + }); + }); + + test('cycles the color scheme and keeps it across loads', async ({ + page, + }) => { + await page.goto(url('/')); + await waitForHydration(page); + const html = page.locator('html'); + await expect(html).toHaveAttribute('data-mode', 'system'); + await page.getByRole('button').click(); + await expect(html).toHaveAttribute('data-mode', 'dark'); + await expect(html).toHaveClass('dark'); + await page.reload(); + await expect(html).toHaveAttribute('data-mode', 'dark'); + await waitForHydration(page); + await page.getByRole('button').click(); + await expect(html).toHaveAttribute('data-mode', 'light'); + await expect(html).not.toHaveClass('dark'); + }); + + test('answers an unknown post with 404', async ({ request }) => { + expect((await request.get(url('/posts/no-such-post'))).status()).toBe(404); + }); +}); diff --git a/e2e/fs-router-nextjs-commerce.spec.ts b/e2e/fs-router-nextjs-commerce.spec.ts new file mode 100644 index 0000000..822b66a --- /dev/null +++ b/e2e/fs-router-nextjs-commerce.spec.ts @@ -0,0 +1,134 @@ +import { expect } from '@playwright/test'; +import type { BrowserContext } from '@playwright/test'; +import { prepareExample, test, waitForHydration } from './utils.js'; + +const startApp = prepareExample('fs-router/nextjs-commerce'); + +test.describe('fs-router/nextjs-commerce', () => { + let port: number; + let stopApp: () => Promise<void>; + const url = (path: string) => `http://localhost:${port}${path}`; + + test.beforeAll(async ({ mode }) => { + ({ port, stopApp } = await startApp(mode)); + }); + + test.afterAll(async () => { + await stopApp(); + }); + + const skipWelcome = (context: BrowserContext) => + context.addCookies([{ name: 'welcome-toast', value: '2', url: url('/') }]); + + test('welcomes a first visit once', async ({ page, context }) => { + await page.goto(url('/')); + await waitForHydration(page); + await expect(page).toHaveTitle('Acme Store'); + const toast = page.getByText('🛍️ Welcome to Next.js Commerce!'); + await expect(toast).toBeVisible(); + await page.getByRole('button', { name: 'Close toast' }).click(); + await expect(toast).toBeHidden(); + expect(await context.cookies()).toContainEqual( + expect.objectContaining({ name: 'welcome-toast', value: '2' }), + ); + }); + + test('searches the catalogue and browses a collection', async ({ + page, + context, + }) => { + await skipWelcome(context); + await page.goto(url('/')); + await waitForHydration(page); + await expect( + page.locator('a[href="/product/acme-hoodie"]').first(), + ).toBeVisible(); + const search = page.locator('input[name="q"]').filter({ visible: true }); + await search.fill('mug'); + await search.press('Enter'); + await expect(page).toHaveURL(url('/search?q=mug')); + await expect(page.getByText('Showing 1 result for "mug"')).toBeVisible(); + await page.getByRole('link', { name: 'Accessories' }).first().click(); + await expect(page).toHaveURL(url('/search/accessories')); + await expect(page.locator('a[href="/product/acme-mug"]')).toBeVisible(); + await expect(page.locator('a[href="/product/acme-hoodie"]')).toHaveCount(0); + }); + + test('keeps a cart across page loads', async ({ page, context }) => { + await skipWelcome(context); + await page.goto(url('/product/acme-cup')); + await waitForHydration(page); + await expect(page).toHaveTitle('Acme Cup | Acme Store'); + await expect + .poll(async () => (await context.cookies()).map(({ name }) => name)) + .toContain('cartId'); + await page.getByRole('button', { name: '16oz' }).click(); + await expect(page).toHaveURL(url('/product/acme-cup?size=16oz')); + await page.getByRole('button', { name: 'Add to cart' }).click(); + const cart = page.getByRole('dialog'); + await expect(cart.getByText('Acme Cup')).toBeVisible(); + await expect(cart.getByText('16oz')).toBeVisible(); + await page.reload(); + await waitForHydration(page); + await page.getByRole('button', { name: 'Open cart' }).click(); + await expect(cart.getByText('Acme Cup')).toBeVisible(); + await cart.getByRole('button', { name: 'Remove cart item' }).click(); + await expect(cart.getByText('Your cart is empty.')).toBeVisible(); + }); + + test('renders content pages and answers unknown products with 404', async ({ + page, + }) => { + await page.goto(url('/about')); + await expect(page).toHaveTitle('About | Acme Store'); + await expect(page.getByRole('heading', { name: 'About' })).toBeVisible(); + const res = await page.goto(url('/product/no-such-product')); + expect(res?.status()).toBe(404); + }); + + test('serves robots.txt, the sitemap and Open Graph images', async ({ + request, + }) => { + expect(await (await request.get(url('/robots.txt'))).text()).toContain( + 'Sitemap: ', + ); + expect(await (await request.get(url('/sitemap.xml'))).text()).toContain( + '/product/acme-cup</loc>', + ); + const image = await request.get(url('/opengraph-image?product=acme-cup')); + expect(image.headers()['content-type']).toBe('image/png'); + }); + + test.describe('without JavaScript', () => { + test.use({ javaScriptEnabled: false }); + + test('serves the metadata of a product', async ({ page }) => { + await page.goto(url('/product/acme-cup')); + await expect(page).toHaveTitle('Acme Cup | Acme Store'); + await expect(page.locator('title')).toHaveCount(1); + await expect(page.locator('meta[name="description"]')).toHaveAttribute( + 'content', + 'A 12oz ceramic cup. Dishwasher safe, microwave optional.', + ); + await expect(page.locator('meta[property="og:image"]')).toHaveAttribute( + 'content', + /\/opengraph-image\?product=acme-cup$/, + ); + const jsonLd = await page + .locator('script[type="application/ld+json"]') + .textContent(); + expect(JSON.parse(jsonLd!)).toMatchObject({ + '@type': 'Product', + name: 'Acme Cup', + }); + }); + + test('keeps hidden products out of search engines', async ({ page }) => { + await page.goto(url('/product/acme-prototype-tee')); + await expect(page.locator('meta[name="robots"]')).toHaveAttribute( + 'content', + 'noindex, nofollow', + ); + }); + }); +}); diff --git a/e2e/fs-router-nextjs-dashboard.spec.ts b/e2e/fs-router-nextjs-dashboard.spec.ts new file mode 100644 index 0000000..1571d49 --- /dev/null +++ b/e2e/fs-router-nextjs-dashboard.spec.ts @@ -0,0 +1,119 @@ +import { expect } from '@playwright/test'; +import type { Page } from '@playwright/test'; +import { prepareExample, test, waitForHydration } from './utils.js'; + +const startApp = prepareExample('fs-router/nextjs-dashboard', { + env: { SESSION_SECRET: 'e2e-secret-of-at-least-24-characters' }, +}); + +test.describe('fs-router/nextjs-dashboard', () => { + let port: number; + let stopApp: () => Promise<void>; + const url = (path: string) => `http://localhost:${port}${path}`; + + test.beforeAll(async ({ mode }) => { + ({ port, stopApp } = await startApp(mode)); + }); + + test.afterAll(async () => { + await stopApp(); + }); + + const logIn = async (page: Page, path: string) => { + await page.goto(url(path)); + await waitForHydration(page); + await page.getByLabel('Email').fill('user@nextmail.com'); + await page.getByLabel('Password').fill('123456'); + await page.getByRole('button', { name: 'Log in' }).click(); + await expect(page).toHaveURL(url(path)); + }; + + test('keeps signed-out requests away from the data', async ({ request }) => { + const res = await request.get(url('/dashboard'), { maxRedirects: 0 }); + expect(res.status()).toBe(307); + expect(res.headers()['location']).toBe('/login?callbackUrl=%2Fdashboard'); + const rsc = await request.get(url('/RSC/R/dashboard/customers.txt')); + expect(await rsc.text()).not.toContain('Evil Rabbit'); + }); + + test('rejects wrong credentials', async ({ page }) => { + await page.goto(url('/login')); + await waitForHydration(page); + await page.getByLabel('Email').fill('user@nextmail.com'); + await page.getByLabel('Password').fill('654321'); + await page.getByRole('button', { name: 'Log in' }).click(); + await expect(page.getByText('Invalid credentials.')).toBeVisible(); + await expect(page).toHaveURL(url('/login')); + }); + + test('logs in, browses the dashboard and signs out', async ({ page }) => { + await logIn(page, '/dashboard'); + await expect(page).toHaveTitle('Dashboard | Acme Dashboard'); + await expect( + page.getByRole('heading', { name: 'Latest Invoices' }), + ).toBeVisible(); + await expect( + page + .getByRole('heading', { name: 'Total Customers' }) + .locator('xpath=../..') + .getByRole('paragraph'), + ).toHaveText('6'); + await page.getByRole('link', { name: 'Customers' }).click(); + await expect(page.getByText('Evil Rabbit').first()).toBeVisible(); + const rsc = await page.request.get(url('/RSC/R/dashboard/customers.txt')); + expect(await rsc.text()).toContain('Evil Rabbit'); + await page.getByRole('button', { name: 'Sign Out' }).click(); + await expect(page).toHaveURL(url('/')); + await page.goto(url('/dashboard')); + await expect(page).toHaveURL(url('/login?callbackUrl=%2Fdashboard')); + }); + + test('searches and pages through invoices', async ({ page }) => { + await logIn(page, '/dashboard/invoices'); + const rows = page.locator('tbody tr'); + await expect(rows).toHaveCount(6); + await page.getByRole('link', { name: '3', exact: true }).click(); + await expect(page).toHaveURL(url('/dashboard/invoices?page=3')); + await expect(rows).toHaveCount(1); + await page.getByPlaceholder('Search invoices...').fill('Lee'); + await expect(page).toHaveURL(url('/dashboard/invoices?page=1&query=Lee')); + await expect(rows.first()).toContainText('Lee Robinson'); + for (const row of await rows.all()) { + await expect(row).toContainText('Lee Robinson'); + } + }); + + test('creates, edits and deletes an invoice', async ({ page }) => { + await logIn(page, '/dashboard/invoices/create'); + await page.getByRole('button', { name: 'Create Invoice' }).click(); + await expect( + page.getByText('Missing Fields. Failed to Create Invoice.'), + ).toBeVisible(); + await page + .getByLabel('Choose customer') + .selectOption({ label: 'Amy Burns' }); + await page.getByLabel('Choose an amount').fill('123.45'); + await page.getByLabel('Paid').check(); + await page.getByRole('button', { name: 'Create Invoice' }).click(); + await expect(page).toHaveURL(url('/dashboard/invoices')); + const created = page.locator('tbody tr', { hasText: '$123.45' }); + await expect(created).toContainText('Amy Burns'); + await created.locator('a[href$="/edit"]').click(); + await expect(page).toHaveTitle('Edit Invoice | Acme Dashboard'); + await page.getByLabel('Choose an amount').fill('678.90'); + await page.getByRole('button', { name: 'Edit Invoice' }).click(); + await expect(page).toHaveURL(url('/dashboard/invoices')); + const edited = page.locator('tbody tr', { hasText: '$678.90' }); + await expect(edited).toContainText('Amy Burns'); + await edited.getByRole('button', { name: 'Delete' }).click(); + await expect(edited).toHaveCount(0); + }); + + test('renders the 404 page', async ({ page }) => { + const res = await page.goto(url('/no-such-page')); + expect(res?.status()).toBe(404); + await expect( + page.getByRole('heading', { name: '404 Not Found' }), + ).toBeVisible(); + }); +}); diff --git a/e2e/fs-router-nextjs-photo-modal.spec.ts b/e2e/fs-router-nextjs-photo-modal.spec.ts new file mode 100644 index 0000000..abd8ff9 --- /dev/null +++ b/e2e/fs-router-nextjs-photo-modal.spec.ts @@ -0,0 +1,66 @@ +import { expect } from '@playwright/test'; +import { prepareExample, test, waitForHydration } from './utils.js'; + +const startApp = prepareExample('fs-router/nextjs-photo-modal'); + +test.describe('fs-router/nextjs-photo-modal', () => { + let port: number; + let stopApp: () => Promise<void>; + const url = (path: string) => `http://localhost:${port}${path}`; + + test.beforeAll(async ({ mode }) => { + ({ port, stopApp } = await startApp(mode)); + }); + + test.afterAll(async () => { + await stopApp(); + }); + + test('opens a photo from the feed in a modal', async ({ page }) => { + await page.goto(url('/')); + await waitForHydration(page); + await expect(page).toHaveTitle('NextGram'); + const feed = page.locator('.feed'); + await feed.evaluate((element) => element.setAttribute('data-kept', '')); + await page.getByRole('link', { name: '2', exact: true }).click(); + const dialog = page.getByRole('dialog'); + await expect(dialog.locator('figure')).toHaveText('2'); + await expect(page).toHaveURL(url('/photos/2')); + await expect(page.locator('.feed[data-kept]')).toBeVisible(); + await page.getByRole('button', { name: 'Close' }).click(); + await expect(dialog).toHaveCount(0); + await expect(page).toHaveURL(url('/')); + await page.getByRole('link', { name: '5', exact: true }).click(); + await expect(dialog.locator('figure')).toHaveText('5'); + await page.keyboard.press('Escape'); + await expect(dialog).toHaveCount(0); + await expect(page).toHaveURL(url('/')); + }); + + test('renders a standalone page when loaded directly', async ({ page }) => { + await page.goto(url('/photos/3')); + await waitForHydration(page); + await expect(page.locator('[data-standalone]')).toHaveText('3'); + await expect(page.locator('.feed')).toBeHidden(); + await expect(page.getByRole('dialog')).toHaveCount(0); + }); + + test('renders a standalone page on reload with the modal open', async ({ + page, + }) => { + await page.goto(url('/')); + await waitForHydration(page); + await page.getByRole('link', { name: '4', exact: true }).click(); + await expect(page.getByRole('dialog').locator('figure')).toHaveText('4'); + await page.reload(); + await waitForHydration(page); + await expect(page.locator('[data-standalone]')).toHaveText('4'); + await expect(page.getByRole('dialog')).toHaveCount(0); + }); + + test('answers a photo outside the static paths with 404', async ({ + request, + }) => { + expect((await request.get(url('/photos/7'))).status()).toBe(404); + }); +}); diff --git a/e2e/fs-router-no-ssr.spec.ts b/e2e/fs-router-no-ssr.spec.ts new file mode 100644 index 0000000..e2e55da --- /dev/null +++ b/e2e/fs-router-no-ssr.spec.ts @@ -0,0 +1,53 @@ +import { expect } from '@playwright/test'; +import { prepareExample, test, waitForHydration } from './utils.js'; + +const startApp = prepareExample('fs-router/no-ssr'); + +test.describe('fs-router/no-ssr', () => { + let port: number; + let stopApp: () => Promise<void>; + + test.beforeAll(async ({ mode }) => { + ({ port, stopApp } = await startApp(mode)); + }); + + test.afterAll(async () => { + await stopApp(); + }); + + for (const [path, text] of [ + ['/', 'Hello world!'], + ['/about', 'The minimal React framework'], + ] as const) { + test(`serves ${path} without server rendering`, async ({ request }) => { + const res = await request.get(`http://localhost:${port}${path}`); + expect(res.status()).toBe(200); + expect(await res.text()).not.toContain(text); + }); + } + + test('renders the home page in the browser', async ({ page }) => { + await page.goto(`http://localhost:${port}/`); + await waitForHydration(page); + await expect( + page.getByRole('heading', { name: 'Waku', exact: true }), + ).toBeVisible(); + await expect(page.getByText('Hello world!')).toBeVisible(); + await expect(page).toHaveTitle('Waku'); + await page.getByRole('button', { name: 'Increment' }).click(); + await expect(page.getByText('Count: 1')).toBeVisible(); + }); + + test('navigates to the about page and back', async ({ page }) => { + await page.goto(`http://localhost:${port}/`); + await waitForHydration(page); + await page.getByRole('link', { name: 'About page' }).click(); + await expect( + page.getByRole('heading', { name: 'About Waku' }), + ).toBeVisible(); + await expect(page).toHaveURL(`http://localhost:${port}/about`); + await expect(page).toHaveTitle('About'); + await page.getByRole('link', { name: 'Return home' }).click(); + await expect(page.getByText('Hello world!')).toBeVisible(); + }); +}); diff --git a/e2e/fs-router-stylex.spec.ts b/e2e/fs-router-stylex.spec.ts new file mode 100644 index 0000000..d699e50 --- /dev/null +++ b/e2e/fs-router-stylex.spec.ts @@ -0,0 +1,30 @@ +import { expect } from '@playwright/test'; +import { prepareExample, test, waitForHydration } from './utils.js'; + +const startApp = prepareExample('fs-router/stylex'); + +test.describe('fs-router/stylex', () => { + let port: number; + let stopApp: () => Promise<void>; + + test.beforeAll(async ({ mode }) => { + ({ port, stopApp } = await startApp(mode)); + }); + + test.afterAll(async () => { + await stopApp(); + }); + + test('styles server and client components', async ({ page }) => { + await page.goto(`http://localhost:${port}/`); + await waitForHydration(page); + await expect(page.getByText('Server Style (green)')).toHaveCSS( + 'border-top-color', + 'rgb(0, 128, 0)', + ); + const button = page.getByRole('button', { name: /^Client Style: / }); + await expect(button).toHaveCSS('border-top-color', 'rgb(255, 165, 0)'); + await button.click(); + await expect(button).toHaveText('Client Style: 1 (orange)'); + }); +}); diff --git a/e2e/fs-router-tanstack-router.spec.ts b/e2e/fs-router-tanstack-router.spec.ts new file mode 100644 index 0000000..435bc98 --- /dev/null +++ b/e2e/fs-router-tanstack-router.spec.ts @@ -0,0 +1,50 @@ +import { expect } from '@playwright/test'; +import { prepareExample, test } from './utils.js'; + +const startApp = prepareExample('fs-router/tanstack-router'); + +test.describe('fs-router/tanstack-router', () => { + let port: number; + let stopApp: () => Promise<void>; + const url = (path: string) => `http://localhost:${port}${path}`; + + test.beforeAll(async ({ mode }) => { + ({ port, stopApp } = await startApp(mode)); + }); + + test.afterAll(async () => { + await stopApp(); + }); + + test('routes on the client with TanStack Router', async ({ page }) => { + await page.goto(url('/')); + await expect( + page.getByRole('heading', { name: 'Welcome Home!' }), + ).toBeVisible(); + await page.getByRole('link', { name: 'About' }).click(); + await expect(page.getByText('Hello from About!')).toBeVisible(); + await expect(page).toHaveURL(url('/about')); + await page.goBack(); + await expect( + page.getByRole('heading', { name: 'Welcome Home!' }), + ).toBeVisible(); + await expect(page).toHaveURL(url('/')); + }); + + test('serves the app for any path', async ({ page }) => { + const res = await page.goto(url('/about')); + expect(res?.status()).toBe(200); + await expect(page.getByText('Hello from About!')).toBeVisible(); + }); + + test('calls a server function', async ({ page }) => { + await page.goto(url('/')); + await expect( + page.getByRole('heading', { name: 'Welcome Home!' }), + ).toBeVisible(); + await page.getByRole('button', { name: 'Get Server Time' }).click(); + await expect( + page.getByText(/^Server Time: \d{4}-\d{2}-\d{2}T[\d:.]+Z$/), + ).toBeVisible(); + }); +}); diff --git a/e2e/fs-router-vanilla-extract.spec.ts b/e2e/fs-router-vanilla-extract.spec.ts new file mode 100644 index 0000000..61f8377 --- /dev/null +++ b/e2e/fs-router-vanilla-extract.spec.ts @@ -0,0 +1,30 @@ +import { expect } from '@playwright/test'; +import { prepareExample, test, waitForHydration } from './utils.js'; + +const startApp = prepareExample('fs-router/vanilla-extract'); + +test.describe('fs-router/vanilla-extract', () => { + let port: number; + let stopApp: () => Promise<void>; + + test.beforeAll(async ({ mode }) => { + ({ port, stopApp } = await startApp(mode)); + }); + + test.afterAll(async () => { + await stopApp(); + }); + + test('styles server and client components', async ({ page }) => { + await page.goto(`http://localhost:${port}/`); + await waitForHydration(page); + await expect(page.getByText('Server Style (green)')).toHaveCSS( + 'border-top-color', + 'rgb(0, 128, 0)', + ); + const button = page.getByRole('button', { name: /^Client Style: / }); + await expect(button).toHaveCSS('border-top-color', 'rgb(255, 165, 0)'); + await button.click(); + await expect(button).toHaveText('Client Style: 1 (orange)'); + }); +}); diff --git a/e2e/minimal-api-api.spec.ts b/e2e/minimal-api-api.spec.ts new file mode 100644 index 0000000..731ee23 --- /dev/null +++ b/e2e/minimal-api-api.spec.ts @@ -0,0 +1,35 @@ +import { expect } from '@playwright/test'; +import { prepareExample, test, waitForHydration } from './utils.js'; + +const startApp = prepareExample('minimal-api/api'); + +test.describe('minimal-api/api', () => { + let port: number; + let stopApp: () => Promise<void>; + const url = (path: string) => `http://localhost:${port}${path}`; + + test.beforeAll(async ({ mode }) => { + ({ port, stopApp } = await startApp(mode)); + }); + + test.afterAll(async () => { + await stopApp(); + }); + + test('fetches the API route from a client component', async ({ page }) => { + await page.goto(url('/')); + await waitForHydration(page); + await expect( + page.getByRole('heading', { name: 'Hello Waku!!' }), + ).toBeVisible(); + await expect(page.getByText('Hello, world')).toBeVisible(); + await page.getByRole('button', { name: 'Increment' }).click(); + await expect(page.getByText('Count: 1')).toBeVisible(); + }); + + test('serves the API route', async ({ request }) => { + const res = await request.get(url('/api/hello')); + expect(res.status()).toBe(200); + expect(await res.text()).toBe('world'); + }); +}); diff --git a/e2e/minimal-api-basic-js.spec.ts b/e2e/minimal-api-basic-js.spec.ts new file mode 100644 index 0000000..bbc7752 --- /dev/null +++ b/e2e/minimal-api-basic-js.spec.ts @@ -0,0 +1,54 @@ +import { expect } from '@playwright/test'; +import { prepareExample, test, waitForHydration } from './utils.js'; + +const startApp = prepareExample('minimal-api/basic-js'); + +test.describe('minimal-api/basic-js', () => { + let port: number; + let stopApp: () => Promise<void>; + const url = (path: string) => `http://localhost:${port}${path}`; + + test.beforeAll(async ({ mode }) => { + ({ port, stopApp } = await startApp(mode)); + }); + + test.afterAll(async () => { + await stopApp(); + }); + + test('renders server and client components', async ({ page }) => { + await page.goto(url('/')); + await waitForHydration(page); + await expect(page).toHaveTitle('Waku'); + await expect( + page.getByRole('heading', { name: 'Hello Waku!!' }), + ).toBeVisible(); + await expect(page.getByText('This is a server component.')).toBeVisible(); + await expect(page.getByText('Hello from server!')).toBeVisible(); + await page.getByRole('button', { name: 'Increment' }).click(); + await expect(page.getByText('Count: 1')).toBeVisible(); + }); + + test('renders per request in DEV and once at build time in PRD', async ({ + page, + mode, + }) => { + const timestamp = page.getByText(/^\d{4}-\d{2}-\d{2}T[\d:.]+Z$/); + await page.goto(url('/')); + const first = await timestamp.textContent(); + await page.reload(); + if (mode === 'PRD') { + await expect(timestamp).toHaveText(first!); + } else { + await expect(timestamp).not.toHaveText(first!); + } + }); + + test('serves the custom 404 page', async ({ page }) => { + const res = await page.goto(url('/no-such-page')); + expect(res?.status()).toBe(404); + await expect( + page.getByRole('heading', { name: 'Custom Not Found Page' }), + ).toBeVisible(); + }); +}); diff --git a/e2e/minimal-api-basic.spec.ts b/e2e/minimal-api-basic.spec.ts new file mode 100644 index 0000000..45ac1d3 --- /dev/null +++ b/e2e/minimal-api-basic.spec.ts @@ -0,0 +1,54 @@ +import { expect } from '@playwright/test'; +import { prepareExample, test, waitForHydration } from './utils.js'; + +const startApp = prepareExample('minimal-api/basic'); + +test.describe('minimal-api/basic', () => { + let port: number; + let stopApp: () => Promise<void>; + const url = (path: string) => `http://localhost:${port}${path}`; + + test.beforeAll(async ({ mode }) => { + ({ port, stopApp } = await startApp(mode)); + }); + + test.afterAll(async () => { + await stopApp(); + }); + + test('renders server and client components', async ({ page }) => { + await page.goto(url('/')); + await waitForHydration(page); + await expect(page).toHaveTitle('Waku'); + await expect( + page.getByRole('heading', { name: 'Hello Waku!!' }), + ).toBeVisible(); + await expect(page.getByText('This is a server component.')).toBeVisible(); + await expect(page.getByText('Hello from server!')).toBeVisible(); + await page.getByRole('button', { name: 'Increment' }).click(); + await expect(page.getByText('Count: 1')).toBeVisible(); + }); + + test('renders per request in DEV and once at build time in PRD', async ({ + page, + mode, + }) => { + const timestamp = page.getByText(/^\d{4}-\d{2}-\d{2}T[\d:.]+Z$/); + await page.goto(url('/')); + const first = await timestamp.textContent(); + await page.reload(); + if (mode === 'PRD') { + await expect(timestamp).toHaveText(first!); + } else { + await expect(timestamp).not.toHaveText(first!); + } + }); + + test('serves the custom 404 page', async ({ page }) => { + const res = await page.goto(url('/no-such-page')); + expect(res?.status()).toBe(404); + await expect( + page.getByRole('heading', { name: 'Custom Not Found Page' }), + ).toBeVisible(); + }); +}); diff --git a/e2e/minimal-api-cookies.spec.ts b/e2e/minimal-api-cookies.spec.ts new file mode 100644 index 0000000..22ae29c --- /dev/null +++ b/e2e/minimal-api-cookies.spec.ts @@ -0,0 +1,38 @@ +import { expect } from '@playwright/test'; +import { prepareExample, test, waitForHydration } from './utils.js'; + +const startApp = prepareExample('minimal-api/cookies'); + +test.describe('minimal-api/cookies', () => { + let port: number; + let stopApp: () => Promise<void>; + const url = (path: string) => `http://localhost:${port}${path}`; + + test.beforeAll(async ({ mode }) => { + ({ port, stopApp } = await startApp(mode)); + }); + + test.afterAll(async () => { + await stopApp(); + }); + + test('counts requests in a cookie', async ({ page }) => { + const res = await page.goto(url('/')); + expect(await res?.headerValue('set-cookie')).toContain('count=1'); + await waitForHydration(page); + await expect(page.getByText('Cookie count: 1')).toBeVisible(); + await expect(page.getByText('Item count: 10')).toBeVisible(); + await page.reload(); + await expect(page.getByText('Cookie count: 2')).toBeVisible(); + await page.getByRole('button', { name: 'Increment' }).click(); + await expect(page.getByText('Count: 1', { exact: true })).toBeVisible(); + }); + + test('reads the cookie sent with the request', async ({ request }) => { + const res = await request.get(url('/'), { + headers: { cookie: 'count=5' }, + }); + expect(res.headers()['set-cookie']).toContain('count=6'); + expect(await res.text()).toMatch(/Cookie count: (<!-- -->)?6/); + }); +}); diff --git a/e2e/minimal-api-css.spec.ts b/e2e/minimal-api-css.spec.ts new file mode 100644 index 0000000..7c2d724 --- /dev/null +++ b/e2e/minimal-api-css.spec.ts @@ -0,0 +1,34 @@ +import { expect } from '@playwright/test'; +import { prepareExample, test } from './utils.js'; + +const startApp = prepareExample('minimal-api/css'); + +test.describe('minimal-api/css', () => { + let port: number; + let stopApp: () => Promise<void>; + + test.beforeAll(async ({ mode }) => { + ({ port, stopApp } = await startApp(mode)); + }); + + test.afterAll(async () => { + await stopApp(); + }); + + test('applies global CSS and a CSS module', async ({ page }) => { + await page.goto(`http://localhost:${port}/`); + const headings = page.getByRole('heading', { name: 'Hello Waku!!' }); + await expect(headings).toHaveCount(2); + await expect(headings.first()).toHaveClass('foo bar'); + await expect(headings.first()).toHaveCSS('color', 'rgb(255, 0, 0)'); + await expect(headings.nth(1)).toHaveCSS( + 'background-color', + 'rgb(255, 192, 203)', + ); + await expect(page.locator('body')).toHaveCSS( + 'background-color', + 'rgb(255, 255, 224)', + ); + await expect(page.getByText('Hello from server!')).toBeVisible(); + }); +}); diff --git a/e2e/minimal-api-form.spec.ts b/e2e/minimal-api-form.spec.ts new file mode 100644 index 0000000..23a1189 --- /dev/null +++ b/e2e/minimal-api-form.spec.ts @@ -0,0 +1,43 @@ +import { expect } from '@playwright/test'; +import { prepareExample, test, waitForHydration } from './utils.js'; + +const startApp = prepareExample('minimal-api/form'); + +test.describe('minimal-api/form', () => { + let port: number; + let stopApp: () => Promise<void>; + const url = (path: string) => `http://localhost:${port}${path}`; + + test.beforeAll(async ({ mode }) => { + ({ port, stopApp } = await startApp(mode)); + }); + + test.afterAll(async () => { + await stopApp(); + }); + + test('submits forms to server functions', async ({ page }) => { + await page.goto(url('/')); + await waitForHydration(page); + await page.locator('input[name="name"]').first().fill('Alice'); + await page.getByRole('button', { name: 'Submit' }).click(); + await expect(page.getByText('Hello Alice from server!')).toBeVisible(); + await page.getByRole('button', { name: 'Increment' }).click(); + await expect(page.getByText('Count: 1')).toBeVisible(); + await page.getByRole('button', { name: 'Increment' }).click(); + await expect(page.getByText('Count: 2')).toBeVisible(); + }); + + test.describe('without JavaScript', () => { + test.use({ javaScriptEnabled: false }); + + test('submits forms as page loads', async ({ page }) => { + await page.goto(url('/')); + await page.locator('input[name="name"]').first().fill('Bob'); + await page.getByRole('button', { name: 'Submit' }).click(); + await expect(page.getByText('Hello Bob from server!')).toBeVisible(); + await page.getByRole('button', { name: 'Increment' }).click(); + await expect(page.getByText('Count: 1')).toBeVisible(); + }); + }); +}); diff --git a/e2e/minimal-api-functions.spec.ts b/e2e/minimal-api-functions.spec.ts new file mode 100644 index 0000000..68166c9 --- /dev/null +++ b/e2e/minimal-api-functions.spec.ts @@ -0,0 +1,45 @@ +import { expect } from '@playwright/test'; +import { prepareExample, test, waitForHydration } from './utils.js'; + +const startApp = prepareExample('minimal-api/functions'); + +test.describe('minimal-api/functions', () => { + let port: number; + let stopApp: () => Promise<void>; + + test.beforeAll(async ({ mode }) => { + ({ port, stopApp } = await startApp(mode)); + }); + + test.afterAll(async () => { + await stopApp(); + }); + + test('calls server functions from client components', async ({ page }) => { + await page.goto(`http://localhost:${port}/`); + await waitForHydration(page); + await page.getByRole('button', { name: 'Click to greet' }).click(); + await expect( + page.getByRole('button', { name: 'Hello c=0 from server!' }), + ).toBeVisible(); + const serverCounter = page.getByText(/^Server counter: \d+$/); + const before = Number((await serverCounter.textContent())!.split(': ')[1]); + await page + .getByRole('button', { name: 'Increment server counter' }) + .click(); + await expect(serverCounter).toHaveText(`Server counter: ${before + 1}`); + }); + + test('passes bound arguments to a server function', async ({ page }) => { + await page.goto(`http://localhost:${port}/`); + await waitForHydration(page); + await page.getByRole('textbox').fill('waku'); + await expect(page.getByText('WAKU', { exact: true })).toBeVisible(); + const response = page.waitForResponse( + (res) => + res.url().includes('/RSC/F/') && res.request().method() === 'POST', + ); + await page.getByRole('button', { name: 'Hello to the server' }).click(); + expect((await response).status()).toBe(200); + }); +}); diff --git a/e2e/minimal-api-islands.spec.ts b/e2e/minimal-api-islands.spec.ts new file mode 100644 index 0000000..cba697a --- /dev/null +++ b/e2e/minimal-api-islands.spec.ts @@ -0,0 +1,49 @@ +import { expect } from '@playwright/test'; +import { prepareExample, test, waitForHydration } from './utils.js'; + +const startApp = prepareExample('minimal-api/islands'); + +test.describe('minimal-api/islands', () => { + let port: number; + let stopApp: () => Promise<void>; + + test.beforeAll(async ({ mode }) => { + ({ port, stopApp } = await startApp(mode)); + }); + + test.afterAll(async () => { + await stopApp(); + }); + + test('fetches the dynamic island into the static page', async ({ page }) => { + await page.goto(`http://localhost:${port}/`); + await waitForHydration(page); + await expect( + page.getByText('This is a static server component.'), + ).toBeVisible(); + await expect( + page.getByText('This is a dynamic server component.'), + ).toBeVisible(); + await expect( + page.getByRole('heading', { name: 'My Counter' }), + ).toBeVisible(); + await page.getByRole('button', { name: 'Increment' }).click(); + await expect(page.getByText('Count: 1')).toBeVisible(); + }); + + test('renders the island per request and the page once in PRD', async ({ + page, + mode, + }) => { + const timestamps = page.getByText(/^\d{4}-\d{2}-\d{2}T[\d:.]+Z$/); + await page.goto(`http://localhost:${port}/`); + await expect(timestamps).toHaveCount(2); + const [island, shell] = await timestamps.allTextContents(); + await page.reload(); + await expect(timestamps).toHaveCount(2); + await expect(timestamps.first()).not.toHaveText(island!); + if (mode === 'PRD') { + await expect(timestamps.last()).toHaveText(shell!); + } + }); +}); diff --git a/e2e/minimal-api-jotai.spec.ts b/e2e/minimal-api-jotai.spec.ts new file mode 100644 index 0000000..e719ee9 --- /dev/null +++ b/e2e/minimal-api-jotai.spec.ts @@ -0,0 +1,33 @@ +import { expect } from '@playwright/test'; +import { prepareExample, test, waitForHydration } from './utils.js'; + +const startApp = prepareExample('minimal-api/jotai'); + +test.describe('minimal-api/jotai', () => { + let port: number; + let stopApp: () => Promise<void>; + + test.beforeAll(async ({ mode }) => { + ({ port, stopApp } = await startApp(mode)); + }); + + test.afterAll(async () => { + await stopApp(); + }); + + test('rerenders the server atom when the client atom changes', async ({ + page, + }) => { + await page.goto(`http://localhost:${port}/`); + await waitForHydration(page); + await expect(page.getByText('Count: 1')).toBeVisible(); + await expect( + page.getByRole('heading', { name: '(doubleCount=2)' }), + ).toBeVisible(); + await page.getByRole('button', { name: 'Increment' }).click(); + await expect(page.getByText('Count: 2')).toBeVisible(); + await expect( + page.getByRole('heading', { name: '(doubleCount=4)' }), + ).toBeVisible(); + }); +}); diff --git a/e2e/minimal-api-nesting.spec.ts b/e2e/minimal-api-nesting.spec.ts new file mode 100644 index 0000000..71f68d1 --- /dev/null +++ b/e2e/minimal-api-nesting.spec.ts @@ -0,0 +1,49 @@ +import { expect } from '@playwright/test'; +import { prepareExample, test, waitForHydration } from './utils.js'; + +const startApp = prepareExample('minimal-api/nesting'); + +test.describe('minimal-api/nesting', () => { + let port: number; + let stopApp: () => Promise<void>; + const url = (path: string) => `http://localhost:${port}${path}`; + + test.beforeAll(async ({ mode }) => { + ({ port, stopApp } = await startApp(mode)); + }); + + test.afterAll(async () => { + await stopApp(); + }); + + test('refetches the inner server component with the outer count', async ({ + page, + }) => { + await page.goto(url('/')); + await waitForHydration(page); + await expect( + page.getByText('This is another server component.'), + ).toBeVisible(); + await expect(page.getByText('The outer count is 0.')).toBeVisible(); + await page + .getByRole('button', { name: 'Increment', exact: true }) + .first() + .click(); + await expect(page.getByText('The outer count is 1.')).toBeVisible(); + await page + .getByRole('button', { name: 'Increment with transition' }) + .click(); + await expect(page.getByText('The outer count is 2.')).toBeVisible(); + await expect(page.getByText('Hello from server!')).toBeVisible(); + }); + + test('renders a root without SSR', async ({ page }) => { + await page.goto(url('/no-ssr')); + await expect(page.getByRole('heading', { name: 'Hello!!' })).toBeVisible(); + await expect( + page.getByText('This is a server component without SSR.'), + ).toBeVisible(); + await page.getByRole('button', { name: 'Increment' }).click(); + await expect(page.getByText('Count: 1')).toBeVisible(); + }); +}); diff --git a/e2e/minimal-api-path-alias.spec.ts b/e2e/minimal-api-path-alias.spec.ts new file mode 100644 index 0000000..f1a13b3 --- /dev/null +++ b/e2e/minimal-api-path-alias.spec.ts @@ -0,0 +1,29 @@ +import { expect } from '@playwright/test'; +import { prepareExample, test, waitForHydration } from './utils.js'; + +const startApp = prepareExample('minimal-api/path-alias'); + +test.describe('minimal-api/path-alias', () => { + let port: number; + let stopApp: () => Promise<void>; + + test.beforeAll(async ({ mode }) => { + ({ port, stopApp } = await startApp(mode)); + }); + + test.afterAll(async () => { + await stopApp(); + }); + + test('resolves aliased imports on the server and the client', async ({ + page, + }) => { + await page.goto(`http://localhost:${port}/`); + await waitForHydration(page); + await expect( + page.getByRole('heading', { name: 'Hello Waku!!' }), + ).toBeVisible(); + await page.getByRole('button', { name: 'Increment' }).click(); + await expect(page.getByText('Count: 1')).toBeVisible(); + }); +}); diff --git a/e2e/minimal-api-promise.spec.ts b/e2e/minimal-api-promise.spec.ts new file mode 100644 index 0000000..d37144f --- /dev/null +++ b/e2e/minimal-api-promise.spec.ts @@ -0,0 +1,34 @@ +import { expect } from '@playwright/test'; +import { prepareExample, test, waitForHydration } from './utils.js'; + +const startApp = prepareExample('minimal-api/promise'); + +test.describe('minimal-api/promise', () => { + let port: number; + let stopApp: () => Promise<void>; + + test.beforeAll(async ({ mode }) => { + ({ port, stopApp } = await startApp(mode)); + }); + + test.afterAll(async () => { + await stopApp(); + }); + + test('resolves a server promise in a client component', async ({ page }) => { + await page.goto(`http://localhost:${port}/`); + await waitForHydration(page); + await expect( + page.getByText('delayedMessage: Hello from server!'), + ).toBeVisible(); + await expect( + page.getByRole('heading', { name: 'A client element' }), + ).toBeVisible(); + await expect( + page.getByText('This is a component without "use client".'), + ).toBeVisible(); + await page.getByRole('button', { name: 'Increment' }).click(); + await expect(page.getByText('Count: 1', { exact: true })).toBeVisible(); + await expect(page.getByText('count: 1', { exact: true })).toBeVisible(); + }); +}); diff --git a/e2e/minimal-api-spa.spec.ts b/e2e/minimal-api-spa.spec.ts new file mode 100644 index 0000000..9d70f58 --- /dev/null +++ b/e2e/minimal-api-spa.spec.ts @@ -0,0 +1,39 @@ +import { expect } from '@playwright/test'; +import { prepareExample, test } from './utils.js'; + +const startApp = prepareExample('minimal-api/spa'); + +test.describe('minimal-api/spa', () => { + let port: number; + let stopApp: () => Promise<void>; + const url = (path: string) => `http://localhost:${port}${path}`; + + test.beforeAll(async ({ mode }) => { + ({ port, stopApp } = await startApp(mode)); + }); + + test.afterAll(async () => { + await stopApp(); + }); + + test('serves the same shell for every path', async ({ request }) => { + for (const path of ['/', '/any/path']) { + const res = await request.get(url(path)); + expect(res.status()).toBe(200); + expect(await res.text()).not.toContain('Hello Client!!'); + } + }); + + test('renders on the client and calls a server function', async ({ + page, + }) => { + await page.goto(url('/')); + await expect( + page.getByRole('heading', { name: 'Hello Client!!' }), + ).toBeVisible(); + await page.getByRole('button', { name: 'Increment' }).click(); + await expect(page.getByText('Count: 1')).toBeVisible(); + await page.getByRole('button', { name: 'Greet' }).click(); + await expect(page.getByText(/^Hello from server \(/)).toBeVisible(); + }); +}); diff --git a/e2e/spec-coverage.spec.ts b/e2e/spec-coverage.spec.ts new file mode 100644 index 0000000..9fd0dfc --- /dev/null +++ b/e2e/spec-coverage.spec.ts @@ -0,0 +1,30 @@ +import { existsSync, readFileSync, readdirSync } from 'node:fs'; +import { expect } from '@playwright/test'; +import { test } from './utils.js'; + +const groups = ['fs-router', 'create-pages', 'define-router', 'minimal-api']; + +test('every example has a spec named after its package', () => { + const missing = groups.flatMap((group) => + readdirSync(new URL(`../${group}`, import.meta.url), { + withFileTypes: true, + }) + .filter((entry) => entry.isDirectory()) + .map( + (entry) => + JSON.parse( + readFileSync( + new URL( + `../${group}/${entry.name}/package.json`, + import.meta.url, + ), + 'utf8', + ), + ).name as string, + ) + .filter( + (name) => !existsSync(new URL(`./${name}.spec.ts`, import.meta.url)), + ), + ); + expect(missing).toEqual([]); +}); diff --git a/e2e/utils.ts b/e2e/utils.ts index 3657cdd..5f6a8e0 100644 --- a/e2e/utils.ts +++ b/e2e/utils.ts @@ -1,7 +1,10 @@ import { exec, spawn } from 'node:child_process'; import type { ChildProcess } from 'node:child_process'; +import { rmSync } from 'node:fs'; import { createConnection, createServer } from 'node:net'; +import { fileURLToPath } from 'node:url'; import { promisify } from 'node:util'; +import { info } from '@actions/core'; import { test as basicTest } from '@playwright/test'; import type { ConsoleMessage, Page } from '@playwright/test'; @@ -53,13 +56,18 @@ export const waitForPortReady = async (port: number): Promise<void> => tryConnect(); }); -export const runShell = (command: string, cwd: string): ChildProcess => +export const runShell = ( + command: string, + cwd: string, + env?: Record<string, string>, +): ChildProcess => spawn(command, { cwd, shell: true, detached: process.platform !== 'win32', windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'], + env: { ...process.env, ...env }, }); export const terminate = async (cp: ChildProcess): Promise<void> => { @@ -79,21 +87,22 @@ const unexpectedErrors: RegExp[] = [ ]; // Expected errors that some examples log on purpose (error boundaries, 404s, -// redirects, etc.). These should not fail the smoke test. +// redirects, etc.). They are left out of the logged server output. export const ignoreErrors: RegExp[] = [ /ExperimentalWarning: Custom ESM Loaders is an experimental feature and might change at any time/, /npm warn Unknown env config "verify-deps-before-run"\./, - /^(Error during rendering: )?Error: Unexpected error\s+at ThrowsComponent/, - /^(Error during rendering: )?Error: Intentional render error\s+at ErrorRender/, + /^(Error during rendering: )?Error: Unexpected error(\\n|\s)+at ThrowsComponent/, + /^(Error during rendering: )?Error: Intentional render error(\\n|\s)+at ErrorRender/, /^Error: Input is required\b/, - /^(Error during rendering: )?Error: 401 Unauthorized\s+at CheckIfAccessDenied/, - /^(Error during rendering: )?Error: Not Found\s+at (Sync|Async)Page/, - /^(Error during rendering: )?Error: Not Found\s+at info/, - /^(Error during rendering: )?Error: Not Found\s+at createCustomError/, - /^(Error during rendering: )?Error: Redirect\s+at info/, - /^(Error during rendering: )?Error: Redirect\s+at createCustomError/, + /^(Error during rendering: )?Error: 401 Unauthorized(\\n|\s)+at CheckIfAccessDenied/, + /^(Error during rendering: )?Error: Unauthorized(\\n|\s)+at requireSession/, + /^(Error during rendering: )?Error: Not Found(\\n|\s)+at (Sync|Async)Page/, + /^(Error during rendering: )?Error: Not Found(\\n|\s)+at info/, + /^(Error during rendering: )?Error: Not Found(\\n|\s)+at createCustomError/, + /^(Error during rendering: )?Error: Redirect(\\n|\s)+at info/, + /^(Error during rendering: )?Error: Redirect(\\n|\s)+at createCustomError/, /^(Error during rendering: )?\[Error: An error occurred in the Server Components render\./, - /^Error: pathname must start with basePath: \/favicon\.ico\s+at removeBase/, + /^Error: pathname must start with basePath: \/favicon\.ico(\\n|\s)+at removeBase/, ]; export const test = basicTest.extend< @@ -113,3 +122,46 @@ export const test = basicTest.extend< page.off('console', callback); }, }); + +const logServerOutput = (cp: ChildProcess) => { + for (const [name, stream] of [ + ['stdout', cp.stdout], + ['stderr', cp.stderr], + ] as const) { + stream?.on('data', (data) => { + if (!ignoreErrors.some((re) => re.test(`${data}`))) { + info(`${name}: ${data}`); + } + }); + } +}; + +export const prepareExample = ( + example: string, + { env }: { env?: Record<string, string> } = {}, +) => { + const cwd = fileURLToPath(new URL(`../${example}`, import.meta.url)); + let built = false; + return async (mode: TestOptions['mode']) => { + if (mode === 'PRD' && !built) { + rmSync(`${cwd}/dist`, { recursive: true, force: true }); + await execAsync('pnpm --silent run build', { + cwd, + env: { ...process.env, ...env }, + }); + built = true; + } + const port = await getAvailablePort(); + const script = mode === 'DEV' ? 'dev' : 'start'; + const cp = runShell(`pnpm --silent run ${script} --port ${port}`, cwd, env); + logServerOutput(cp); + await waitForPortReady(port); + return { port, stopApp: () => terminate(cp) }; + }; +}; + +export const waitForHydration = async (page: Page) => { + await page.waitForFunction(() => + Object.keys(document.body).some((key) => key.startsWith('__reactFiber')), + ); +}; diff --git a/fs-router/data-fetching/src/lib/index.ts b/fs-router/data-fetching/src/lib/index.ts index 5e8506d..ca82bb5 100644 --- a/fs-router/data-fetching/src/lib/index.ts +++ b/fs-router/data-fetching/src/lib/index.ts @@ -17,9 +17,11 @@ const shuffle = (array: Array<any>) => { .map(({ value }: any) => value); }; +export const toPath = (slug: string) => slug.replaceAll(' ', '-'); + /** * Mock static paths */ export const getPokemonPaths = async (): Promise<string[]> => { - return pokemon.map((row: any) => row.slug); + return pokemon.map((row: any) => toPath(row.slug)); }; diff --git a/fs-router/data-fetching/src/pages/[slug].tsx b/fs-router/data-fetching/src/pages/[slug].tsx index c3e37be..a2b8052 100644 --- a/fs-router/data-fetching/src/pages/[slug].tsx +++ b/fs-router/data-fetching/src/pages/[slug].tsx @@ -1,10 +1,10 @@ import { Link } from 'waku'; import type { PageProps } from 'waku/router'; -import { getPokemonPaths } from '../lib'; +import { getPokemonPaths, toPath } from '../lib'; import { pokemon } from '../lib/pokemon'; export default async function PokemonPage({ slug }: PageProps<'/[slug]'>) { - const pokemon = await getPokemon(slug.replaceAll('-', ' ')); + const pokemon = await getPokemon(slug); if (!pokemon) { return null; @@ -69,7 +69,7 @@ export default async function PokemonPage({ slug }: PageProps<'/[slug]'>) { } const getPokemon = async (slug: string) => { - return pokemon.find((row: any) => row.slug === slug) ?? null; + return pokemon.find((row: any) => toPath(row.slug) === slug) ?? null; }; export const getConfig = async () => { diff --git a/fs-router/data-fetching/src/pages/index.tsx b/fs-router/data-fetching/src/pages/index.tsx index 45a534a..e608e5c 100644 --- a/fs-router/data-fetching/src/pages/index.tsx +++ b/fs-router/data-fetching/src/pages/index.tsx @@ -1,6 +1,6 @@ import { Link } from 'waku'; import { Reload } from '../components/reload'; -import { sql } from '../lib'; +import { sql, toPath } from '../lib'; export default async function HomePage() { const { rows } = await sql`SELECT * FROM pokemon ORDER BY RANDOM() LIMIT 9`; @@ -21,7 +21,7 @@ export default async function HomePage() { {rows.map((row) => ( <li key={row.id}> <Link - to={`/${row.slug}`} + to={`/${toPath(row.slug)}`} className="flex aspect-square w-full shrink-0 flex-col items-center justify-center rounded-xl bg-gray-50 p-3 text-gray-950 transition-colors duration-500 ease-in-out hover:bg-gray-200" > <img diff --git a/fs-router/nextjs-commerce/src/pages/_layout.tsx b/fs-router/nextjs-commerce/src/pages/_layout.tsx index bf69fd9..27a7c8e 100644 --- a/fs-router/nextjs-commerce/src/pages/_layout.tsx +++ b/fs-router/nextjs-commerce/src/pages/_layout.tsx @@ -13,7 +13,6 @@ export default async function RootLayout({ children }: { children: ReactNode }) return ( <> <title>{process.env.SITE_NAME || 'Acme Store'} -
diff --git a/minimal-api/jotai/src/waku.server.tsx b/minimal-api/jotai/src/waku.server.tsx index 4897834..c513699 100644 --- a/minimal-api/jotai/src/waku.server.tsx +++ b/minimal-api/jotai/src/waku.server.tsx @@ -17,19 +17,11 @@ export default adapter({ ); } }, - handleBuild: async ({ - rscPath2pathname, - renderRsc, - renderHtml, - generateFile, - }) => { - const rscPath = ''; + handleBuild: async ({ renderRsc, renderHtml, generateFile }) => { const stream = await renderRsc({ App: , }); - const [stream1, stream2] = stream.tee(); - await generateFile(rscPath2pathname(rscPath), stream1); - const res = await renderHtml(stream2, , { rscPath }); + const res = await renderHtml(stream, , { rscPath: '' }); await generateFile('index.html', res.body!); }, }); diff --git a/minimal-api/nesting/src/waku.server.tsx b/minimal-api/nesting/src/waku.server.tsx index 81fb053..a3b244f 100644 --- a/minimal-api/nesting/src/waku.server.tsx +++ b/minimal-api/nesting/src/waku.server.tsx @@ -32,6 +32,9 @@ export default adapter({ { rscPath: '' }, ); } + if (input.type === 'http' && input.pathname === '/no-ssr') { + return 'fallback'; + } }, handleBuild: async ({ renderRsc, @@ -45,7 +48,9 @@ export default adapter({ }); await generateFile(rscPath2pathname(''), body); for (const count of [1, 2, 3, 4, 5]) { - const innerBody = await renderRsc({ App: }); + const innerBody = await renderRsc({ + InnerApp: , + }); await generateFile(rscPath2pathname(`InnerApp=${count}`), innerBody); } await generateDefaultHtml('index.html');