Skip to content

Commit 6c39c98

Browse files
committed
feat: migrate from Vitest browser mode to Playwright for E2E testing
- Replace @vitest/browser-playwright with @playwright/test for E2E tests - Add playwright.config.ts with base configuration and test directory - Add test:e2e npm script for running Playwright tests - Create new Playwright test for updateTag Server Action functionality - Add update-tag-test page to Next.js 16 test app for E2E testing - Disable Vitest browser mode in vitest.browser.config.ts - Add type annotations to integration
1 parent 7bc78be commit 6c39c98

File tree

9 files changed

+161
-36
lines changed

9 files changed

+161
-36
lines changed

package.json

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,8 @@
1919
"test:unit": "vitest --config vite.config.ts src/**/*.test.ts src/**/*.test.tsx",
2020
"test:integration": "vitest --config vite.config.ts ./test/integration/nextjs-cache-handler.integration.test.ts",
2121
"prepare": "./scripts/prepare.sh",
22-
"test:browser": "vitest --config=vitest.browser.config.ts"
22+
"test:browser": "vitest --config=vitest.browser.config.ts",
23+
"test:e2e": "playwright test"
2324
},
2425
"main": "dist/index.js",
2526
"module": "dist/index.mjs",
@@ -69,13 +70,13 @@
6970
"devDependencies": {
7071
"@commitlint/cli": "^19.6.0",
7172
"@commitlint/config-conventional": "^19.6.0",
73+
"@playwright/test": "^1.56.1",
7274
"@semantic-release/changelog": "^6.0.3",
7375
"@semantic-release/git": "^10.0.1",
7476
"@semantic-release/github": "^11.0.1",
7577
"@semantic-release/npm": "^12.0.1",
7678
"@typescript-eslint/eslint-plugin": "^8.15.0",
7779
"@typescript-eslint/parser": "^8.15.0",
78-
"@vitest/browser-playwright": "^4.0.13",
7980
"@vitest/coverage-v8": "^4.0.13",
8081
"@vitest/ui": "^4.0.13",
8182
"eslint": "^9.15.0",

playwright.config.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import { defineConfig } from '@playwright/test';
2+
3+
export default defineConfig({
4+
testDir: 'tests',
5+
use: {
6+
baseURL: process.env.PLAYWRIGHT_BASE_URL || 'http://localhost:3000',
7+
trace: 'on-first-retry',
8+
},
9+
});

pnpm-lock.yaml

Lines changed: 22 additions & 7 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

test-results/.last-run.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
{
2+
"status": "passed",
3+
"failedTests": []
4+
}
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import { test, expect } from 'vitest';
2+
3+
// Base URL of the running Next 16 app.
4+
// Start it separately, e.g.:
5+
// cd test/integration/next-app-16-0-3 && pnpm dev
6+
// and ensure it listens on the same origin as below.
7+
const BASE_URL =
8+
(globalThis as any).NEXT_BROWSER_BASE_URL || 'http://localhost:3000';
9+
10+
// This test exercises a real Server Action that calls updateTag.
11+
// It does not assert Redis state, but it verifies that calling the
12+
// server action endpoint does not trigger the "Server Action only" error.
13+
14+
test('Server Action calling updateTag responds without server-action-only error', async () => {
15+
// 1) Fetch the SSR HTML for the page containing the Server Action form
16+
const res = await fetch(`${BASE_URL}/update-tag-test`);
17+
expect(res.status).toBeLessThan(500);
18+
const html = await res.text();
19+
20+
// 2) Parse the HTML to find the form action URL that Next generated
21+
const parser = new DOMParser();
22+
const doc = parser.parseFromString(html, 'text/html');
23+
const form = doc.querySelector('form');
24+
expect(form).not.toBeNull();
25+
26+
const actionAttr = form!.getAttribute('action');
27+
expect(actionAttr).toBeTruthy();
28+
29+
const actionUrl = new URL(actionAttr!, BASE_URL).toString();
30+
31+
// 3) Call the Server Action endpoint directly.
32+
// If updateTag were not allowed here, Next.js would respond with
33+
// an error page that contains the specific error message.
34+
const actionRes = await fetch(actionUrl, { method: 'POST' });
35+
const actionText = await actionRes.text();
36+
37+
expect(actionRes.status).toBeLessThan(500);
38+
expect(actionText).not.toContain(
39+
'updateTag can only be called from within a Server Action',
40+
);
41+
});
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import { updateTag } from 'next/cache';
2+
3+
export const dynamic = 'force-dynamic';
4+
5+
let clicks = 0;
6+
7+
async function increment() {
8+
'use server';
9+
10+
// Simulate a mutation and call updateTag with a test tag
11+
clicks++;
12+
updateTag('update-tag-test');
13+
}
14+
15+
export default function UpdateTagTestPage() {
16+
return (
17+
<main style={{ padding: 40, fontFamily: 'system-ui, sans-serif' }}>
18+
<h1>UpdateTag Test</h1>
19+
<form action={increment}>
20+
<p data-testid="clicks">Clicks: {clicks}</p>
21+
<button type="submit">Increment</button>
22+
</form>
23+
</main>
24+
);
25+
}

test/integration/nextjs-cache-handler.integration.test.ts

Lines changed: 22 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
2-
import { spawn } from 'child_process';
2+
import { ChildProcessWithoutNullStreams, spawn } from 'child_process';
33
import fetch from 'node-fetch';
44
import { createClient, RedisClientType } from 'redis';
55
import { join } from 'path';
@@ -16,7 +16,7 @@ const NEXT_START_URL = `http://localhost:${NEXT_START_PORT}`;
1616

1717
const REDIS_BACKGROUND_SYNC_DELAY = 250; //ms delay to prevent flaky tests in slow CI environments
1818

19-
let nextProcess;
19+
let nextProcess: ChildProcessWithoutNullStreams;
2020
let redisClient: RedisClientType;
2121

2222
async function delay(ms: number) {
@@ -167,9 +167,9 @@ describe('Next.js Turbo Redis Cache Integration', () => {
167167
expect(keys.length).toBeGreaterThan(0);
168168

169169
// check the content of redis key
170-
const value = await redisClient.get(
170+
const value = (await redisClient.get(
171171
process.env.VERCEL_URL + '/api/cached-static-fetch',
172-
);
172+
)) as string;
173173
expect(value).toBeDefined();
174174
const cacheEntry: CacheEntry = JSON.parse(value);
175175

@@ -240,10 +240,10 @@ describe('Next.js Turbo Redis Cache Integration', () => {
240240
);
241241
expect(keys.length).toBe(1);
242242

243-
const hashmap = await redisClient.hGet(
243+
const hashmap = (await redisClient.hGet(
244244
process.env.VERCEL_URL + '__sharedTags__',
245245
'/api/cached-static-fetch',
246-
);
246+
)) as string;
247247
expect(JSON.parse(hashmap)).toEqual([
248248
'_N_T_/layout',
249249
'_N_T_/api/layout',
@@ -519,10 +519,10 @@ describe('Next.js Turbo Redis Cache Integration', () => {
519519
);
520520
expect(keys.length).toBe(1);
521521

522-
const hashmap = await redisClient.hGet(
522+
const hashmap = (await redisClient.hGet(
523523
process.env.VERCEL_URL + '__sharedTags__',
524524
cacheEntryKey,
525-
);
525+
)) as string;
526526
expect(JSON.parse(hashmap)).toEqual([
527527
'revalidated-fetch-revalidate3-nested-fetch-in-api-route',
528528
]);
@@ -574,10 +574,10 @@ describe('Next.js Turbo Redis Cache Integration', () => {
574574
);
575575
expect(keys.length).toBe(1);
576576

577-
const hashmap = await redisClient.hGet(
577+
const hashmap = (await redisClient.hGet(
578578
process.env.VERCEL_URL + '__sharedTags__',
579579
cacheEntryKey,
580-
);
580+
)) as string;
581581
expect(JSON.parse(hashmap)).toEqual([
582582
'revalidated-fetch-revalidate3-nested-fetch-in-api-route',
583583
]);
@@ -627,9 +627,9 @@ describe('Next.js Turbo Redis Cache Integration', () => {
627627
});
628628

629629
it('The data in the redis key should match the expected format', async () => {
630-
const data = await redisClient.get(
630+
const data = (await redisClient.get(
631631
process.env.VERCEL_URL + '/pages/no-fetch/default-page',
632-
);
632+
)) as string;
633633
expect(data).toBeDefined();
634634
const cacheEntry: CacheEntry = JSON.parse(data);
635635

@@ -724,10 +724,10 @@ describe('Next.js Turbo Redis Cache Integration', () => {
724724
);
725725
expect(keys.length).toBe(1);
726726

727-
const hashmap = await redisClient.hGet(
727+
const hashmap = (await redisClient.hGet(
728728
process.env.VERCEL_URL + '__sharedTags__',
729729
'/pages/no-fetch/default-page',
730-
);
730+
)) as string;
731731
expect(JSON.parse(hashmap)).toEqual([
732732
'_N_T_/layout',
733733
'_N_T_/pages/layout',
@@ -780,10 +780,10 @@ describe('Next.js Turbo Redis Cache Integration', () => {
780780
expect(keys3.length).toBe(1);
781781

782782
// test shared tag hashmap to be set for all keys
783-
const hashmap1 = await redisClient.hGet(
783+
const hashmap1 = (await redisClient.hGet(
784784
process.env.VERCEL_URL + '__sharedTags__',
785785
'/pages/revalidated-fetch/revalidate15--default-page',
786-
);
786+
)) as string;
787787
expect(JSON.parse(hashmap1)).toEqual([
788788
'_N_T_/layout',
789789
'_N_T_/pages/layout',
@@ -793,17 +793,17 @@ describe('Next.js Turbo Redis Cache Integration', () => {
793793
'_N_T_/pages/revalidated-fetch/revalidate15--default-page',
794794
'revalidated-fetch-revalidate15-default-page',
795795
]);
796-
const hashmap2 = await redisClient.hGet(
796+
const hashmap2 = (await redisClient.hGet(
797797
process.env.VERCEL_URL + '__sharedTags__',
798798
'e978cf5ddb8bf799209e828635cfe9ae6862f6735cea97f01ab752ff6fa489b4',
799-
);
799+
)) as string;
800800
expect(JSON.parse(hashmap2)).toEqual([
801801
'revalidated-fetch-revalidate15-default-page',
802802
]);
803-
const hashmap3 = await redisClient.hGet(
803+
const hashmap3 = (await redisClient.hGet(
804804
process.env.VERCEL_URL + '__sharedTags__',
805805
'/api/revalidated-fetch',
806-
);
806+
)) as string;
807807
expect(JSON.parse(hashmap3)).toEqual([
808808
'_N_T_/layout',
809809
'_N_T_/api/layout',
@@ -872,10 +872,10 @@ describe('Next.js Turbo Redis Cache Integration', () => {
872872
process.env.VERCEL_URL + '/api/revalidated-fetch',
873873
);
874874
expect(keys3.length).toBe(1);
875-
const hashmap3 = await redisClient.hGet(
875+
const hashmap3 = (await redisClient.hGet(
876876
process.env.VERCEL_URL + '__sharedTags__',
877877
'/api/revalidated-fetch',
878-
);
878+
)) as string;
879879
expect(JSON.parse(hashmap3)).toEqual([
880880
'_N_T_/layout',
881881
'_N_T_/api/layout',

tests/update-tag.spec.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import { test, expect } from '@playwright/test';
2+
3+
// This test assumes the Next 16 app is already running, e.g.:
4+
// cd test/integration/next-app-16-0-3 && pnpm dev
5+
// on the same origin as baseURL.
6+
7+
test('button click triggers Server Action using updateTag', async ({
8+
page,
9+
}) => {
10+
await page.goto('/update-tag-test');
11+
12+
await expect(page.getByText('UpdateTag Test')).toBeVisible();
13+
14+
const clicks = page.getByTestId('clicks');
15+
const before = await clicks.textContent();
16+
17+
await page.getByRole('button', { name: 'Increment' }).click();
18+
19+
// Wait for network and any navigation caused by the Server Action
20+
await page.waitForLoadState('networkidle');
21+
22+
const after = await clicks.textContent();
23+
24+
// Server Action should have run and updated the UI
25+
expect(after).not.toBe(before);
26+
27+
// Ensure there is no error message about updateTag usage
28+
const errorLocator = page.getByText(
29+
'updateTag can only be called from within a Server Action',
30+
{ exact: false },
31+
);
32+
await expect(errorLocator).toHaveCount(0);
33+
});

0 commit comments

Comments
 (0)