Repository: vercel/next.js
Pull request: test: improve action test reliability

## Summary

  This PR improves test reliability for the app-dir actions test suite by addressing race conditions and modernizing file system operations.

  ## Changes

  ### Test Improvements
  - **Fixed race conditions** in revalidation tests by ensuring proper sequencing of assertions
    - URL assertions now occur before DOM checks to prevent flaky failures
    - Added explicit waits and retries for revalidated content
    - Improved test flow with proper page transition checks

  ### Code Modernization
  - **Updated file system operations** to use modern Node.js APIs
    - Migrated from `fs-extra` to native `node:fs/promises`
    - Replaced sync operations with async equivalents for better performance
    - Updated import paths to use `node:` protocol for built-in modules

  ### Specific Fixes
  - Fixed timing issues in the revalidate path/tag tests where assertions were checking values before revalidation completed
  - Added proper waiting for deploy environments where revalidation may have eventual consistency
  - Improved cookie handling by reading the value once to avoid potential race conditions
  - Added semantic `id="title"` to help with page transition assertions

Changed files:
filename: test/e2e/app-dir/actions/app-action.test.ts
patch: @@ -8,10 +8,10 @@ import {
   getRedboxSource,
 } from 'next-test-utils'
 import type { Request, Response } from 'playwright'
-import fs from 'fs-extra'
-import nodeFs from 'fs'
-import { join } from 'path'
+import fs from 'node:fs/promises'
+import { join } from 'node:path'
 import { outdent } from 'outdent'
+import { setTimeout } from 'node:timers/promises'
 
 const GENERIC_RSC_ERROR =
   'Error: An error occurred in the Server Components render. The specific message is omitted in production builds to avoid leaking sensitive details. A digest property is included on this error instance which may provide additional details about the nature of the error.'
@@ -540,10 +540,10 @@ describe('app-dir action handling', () => {
     // this triggers a revalidate + redirect in a client component
     await browser.elementById('redirect-revalidate-client').click()
     await retry(async () => {
+      expect(await browser.url()).toBe(`${next.url}/revalidate?foo=bar`)
+
       const newJustPutIt = await browser.elementById('justputit').text()
       expect(newJustPutIt).not.toBe(initialJustPutit)
-
-      expect(await browser.url()).toBe(`${next.url}/revalidate?foo=bar`)
     })
 
     // this triggers a revalidate + redirect in a server component
@@ -593,7 +593,8 @@ describe('app-dir action handling', () => {
       beforePageLoad(page) {
         page.on('request', (request) => {
           const url = new URL(request.url())
-          if (url.pathname === '/server') {
+          // Only count POST requests to /server (form submissions)
+          if (url.pathname === '/server' && request.method() === 'POST') {
             requestCount++
           }
         })
@@ -956,16 +957,21 @@ describe('app-dir action handling', () => {
   if (isNextStart) {
     it('should not expose action content in sourcemaps', async () => {
       // We check all sourcemaps in the `static` folder for sensitive information given that chunking
-      const sourcemaps = nodeFs
-        .readdirSync(join(next.testDir, '.next', 'static'), {
+      const sourcemaps = await fs
+        .readdir(join(next.testDir, '.next', 'static'), {
           recursive: true,
           encoding: 'utf8',
         })
-        .filter((f) => f.endsWith('.js.map'))
-        .map((f) =>
-          nodeFs.readFileSync(join(next.testDir, '.next', 'static', f), {
-            encoding: 'utf8',
-          })
+        .then((files) =>
+          Promise.all(
+            files
+              .filter((f) => f.endsWith('.js.map'))
+              .map((f) =>
+                fs.readFile(join(next.testDir, '.next', 'static', f), {
+                  encoding: 'utf8',
+                })
+              )
+          )
         )
 
       expect(sourcemaps).not.toBeEmpty()
@@ -1039,23 +1045,22 @@ describe('app-dir action handling', () => {
     it('should bundle external libraries if they are on the action layer', async () => {
       await next.fetch('/client')
       const pageBundle = await fs.readFile(
-        join(next.testDir, '.next', 'server', 'app', 'client', 'page.js')
+        join(next.testDir, '.next', 'server', 'app', 'client', 'page.js'),
+        { encoding: 'utf8' }
       )
       if (isTurbopack) {
-        const chunkPaths = pageBundle
-          .toString()
-          .matchAll(/loadChunk\("([^"]*)"\)/g)
-        // @ts-ignore
+        const chunkPaths = pageBundle.matchAll(/loadChunk\("([^"]*)"\)/g)
         const reads = [...chunkPaths].map(async (match) => {
           const bundle = await fs.readFile(
-            join(next.testDir, '.next', ...match[1].split(/[\\/]/g))
+            join(next.testDir, '.next', ...match[1].split(/[\\/]/g)),
+            { encoding: 'utf8' }
           )
-          return bundle.toString().includes('node_modules/nanoid/index.js')
+          return bundle.includes('node_modules/nanoid/index.js')
         })
 
         expect(await Promise.all(reads)).toContain(true)
       } else {
-        expect(pageBundle.toString()).toContain('node_modules/nanoid/index.js')
+        expect(pageBundle).toContain('node_modules/nanoid/index.js')
       }
     })
   }
@@ -1519,7 +1524,7 @@ describe('app-dir action handling', () => {
         const browser = await next.browser('/revalidate')
         await browser.refresh()
 
-        const thankYouNext = await browser.elementByCss('#thankyounext').text()
+        const original = await browser.elementByCss('#thankyounext').text()
 
         await browser.elementByCss('#another').click()
         await retry(async () => {
@@ -1528,47 +1533,54 @@ describe('app-dir action handling', () => {
           )
         })
 
-        const newThankYouNext = await browser
-          .elementByCss('#thankyounext')
-          .text()
-
-        // Should be the same number although in serverless
-        // it might be eventually consistent
+        // Should be the same number although in serverless it might be
+        // eventually consistent.
         if (!isNextDeploy) {
-          expect(thankYouNext).toEqual(newThankYouNext)
+          await retry(async () => {
+            const another = await browser.elementByCss('#thankyounext').text()
+            expect(another).toEqual(original)
+          })
         }
 
         await browser.elementByCss('#back').click()
-
-        // Should be different
-        let revalidatedThankYouNext
         await retry(async () => {
-          switch (type) {
-            case 'tag':
-              await browser.elementByCss('#revalidate-thankyounext').click()
-              break
-            case 'path':
-              await browser.elementByCss('#revalidate-path').click()
-              break
-            default:
-              throw new Error(`Invalid type: ${type}`)
-          }
+          expect(await browser.elementByCss('#title').text()).toBe('revalidate')
+        })
 
-          revalidatedThankYouNext = await browser
-            .elementByCss('#thankyounext')
-            .text()
+        switch (type) {
+          case 'tag':
+            await browser.elementByCss('#revalidate-thankyounext').click()
+            break
+          case 'path':
+            await browser.elementByCss('#revalidate-path').click()
+            break
+          default:
+            throw new Error(`Invalid type: ${type}`)
+        }
 
-          expect(thankYouNext).not.toBe(revalidatedThankYouNext)
+        // Give some time for it to be revalidated.
+        if (isNextDeploy) {
+          await setTimeout(5000)
+        }
+
+        // Should be different
+        let revalidated
+        await retry(async () => {
+          revalidated = await browser.elementByCss('#thankyounext').text()
+          expect(revalidated).not.toBe(original)
         })
 
         await browser.elementByCss('#another').click()
+        await retry(async () => {
+          expect(await browser.elementByCss('#title').text()).toBe(
+            'another route'
+          )
+        })
 
         // The other page should be revalidated too
         await retry(async () => {
-          const newThankYouNext = await browser
-            .elementByCss('#thankyounext')
-            .text()
-          expect(revalidatedThankYouNext).toBe(newThankYouNext)
+          const another = await browser.elementByCss('#thankyounext').text()
+          expect(another).toBe(revalidated)
         })
       }
     )

filename: test/e2e/app-dir/actions/app/revalidate-2/page.js
patch: @@ -3,6 +3,7 @@ import { cookies } from 'next/headers'
 import Link from 'next/link'
 
 export default async function Page() {
+  const randomCookie = (await cookies()).get('random')
   const data = await fetch(
     'https://next-data-api-endpoint.vercel.app/api/random?page',
     {
@@ -33,9 +34,7 @@ export default async function Page() {
       </form>
       <p>
         random cookie:{' '}
-        <span id="random-cookie">
-          {JSON.stringify((await cookies()).get('random'))}
-        </span>
+        <span id="random-cookie">{JSON.stringify(randomCookie)}</span>
       </p>
     </>
   )

filename: test/e2e/app-dir/actions/app/revalidate/page.js
patch: @@ -6,6 +6,7 @@ import { cookies } from 'next/headers'
 import RedirectClientComponent from './client'
 
 export default async function Page() {
+  const cookie = (await cookies()).get('random')
   const data = await fetch(
     'https://next-data-api-endpoint.vercel.app/api/random?page',
     {
@@ -22,7 +23,7 @@ export default async function Page() {
 
   return (
     <>
-      <p>/revalidate</p>
+      <h1 id="title">revalidate</h1>
       <p>
         {' '}
         revalidate (tags: thankyounext): <span id="thankyounext">
@@ -39,10 +40,7 @@ export default async function Page() {
         <span id="justputit">{data2}</span>
       </p>
       <p>
-        random cookie:{' '}
-        <span id="random-cookie">
-          {JSON.stringify((await cookies()).get('random'))}
-        </span>
+        random cookie: <span id="random-cookie">{JSON.stringify(cookie)}</span>
       </p>
       <form>
         <button