Repository: vercel/next.js
Pull request: Add RDC action test to deploy manifest and update PR test trigger

x-ref: https://github.com/vercel/next.js/pull/91163

Changed files:
filename: scripts/get-changed-tests.mjs
patch: @@ -1,9 +1,114 @@
 // @ts-check
 import fs from 'fs/promises'
 import execa from 'execa'
+import { createRequire } from 'module'
 import path from 'path'
 import { getDiffRevision, getGitInfo } from './git-info.mjs'
 
+const require = createRequire(import.meta.url)
+const glob = require('glob')
+const {
+  getTestFilterFromManifest,
+  mergeManifests,
+} = require('../test/get-test-filter')
+
+const DEFAULT_DEPLOY_TESTS_MANIFEST_PATH = 'test/deploy-tests-manifest.json'
+const TEST_FILE_REGEX = /^test\/.*?\.test\.(js|ts|tsx)$/
+
+function normalizePath(file) {
+  return file.replace(/\\/g, '/')
+}
+
+function getExcludedCases(suite = {}) {
+  return new Set([...(suite.failed ?? []), ...(suite.flakey ?? [])])
+}
+
+function getExternalTestsFilterPaths() {
+  const externalTestsFilters =
+    process.env.NEXT_EXTERNAL_TESTS_FILTERS ??
+    DEFAULT_DEPLOY_TESTS_MANIFEST_PATH
+
+  const manifestPaths = new Map()
+
+  for (const manifestPath of externalTestsFilters.split(',')) {
+    if (!manifestPath.trim()) {
+      continue
+    }
+
+    const absolutePath = path.resolve(process.cwd(), manifestPath)
+    const repoRelativePath = normalizePath(
+      path.relative(process.cwd(), absolutePath)
+    )
+
+    manifestPaths.set(repoRelativePath, {
+      absolutePath,
+      repoRelativePath,
+    })
+  }
+
+  return [...manifestPaths.values()]
+}
+
+function getDeployTestsFromManifest(manifest) {
+  const testFilter = getTestFilterFromManifest(manifest)
+  if (!testFilter) {
+    return []
+  }
+
+  const tests = glob
+    .sync('test/e2e/**/*.test.{js,ts,tsx}', {
+      cwd: process.cwd(),
+      ignore: '**/node_modules/**',
+      nodir: true,
+    })
+    .map((file) => ({ file: normalizePath(file), excludedCases: [] }))
+
+  return testFilter(tests).map((test) => test.file)
+}
+
+export function getDeployManifestChangedTests(
+  currentManifest,
+  previousManifest
+) {
+  if (!currentManifest) {
+    return []
+  }
+
+  const previousVersion2Manifest = previousManifest ?? {
+    version: 2,
+    suites: {},
+    rules: { include: [], exclude: [] },
+  }
+  const previousIncludedTests = new Set(
+    getDeployTestsFromManifest(previousVersion2Manifest)
+  )
+
+  const changedTests = new Set()
+
+  for (const file of getDeployTestsFromManifest(currentManifest)) {
+    const currentExcludedCases = getExcludedCases(
+      currentManifest.suites?.[file]
+    )
+    const previousExcludedCases = getExcludedCases(
+      previousVersion2Manifest.suites?.[file]
+    )
+
+    if (!previousIncludedTests.has(file)) {
+      changedTests.add(file)
+      continue
+    }
+
+    for (const testCase of previousExcludedCases) {
+      if (!currentExcludedCases.has(testCase)) {
+        changedTests.add(file)
+        break
+      }
+    }
+  }
+
+  return [...changedTests]
+}
+
 /**
  * Detects changed tests files by comparing the current branch with `origin/canary`
  * Returns tests separated by test mode (dev/prod), as well as the corresponding commit hash
@@ -17,7 +122,7 @@ export default async function getChangedTests() {
 
   if (isCanary) {
     console.log(`Skipping flake detection for canary`)
-    return { devTests: [], prodTests: [], deployTests: [] }
+    return { devTests: [], prodTests: [], deployTests: [], commitSha }
   }
 
   const diffRevision = await getDiffRevision()
@@ -42,46 +147,80 @@ export default async function getChangedTests() {
 
   // run each test 3 times in each test mode (if E2E) with no-retrying
   // and if any fail it's flakey
-  const devTests = []
-  const prodTests = []
-  const deployTests = []
+  const devTests = new Set()
+  const prodTests = new Set()
+  const deployTests = new Set()
 
   for (let file of changedFiles) {
     // normalize slashes
-    file = file.replace(/\\/g, '/')
+    file = normalizePath(file)
     const fileExists = await fs
       .access(path.join(process.cwd(), file), fs.constants.F_OK)
       .then(() => true)
       .catch(() => false)
 
-    if (fileExists && file.match(/^test\/.*?\.test\.(js|ts|tsx)$/)) {
+    if (fileExists && file.match(TEST_FILE_REGEX)) {
       if (file.startsWith('test/e2e/')) {
-        devTests.push(file)
-        prodTests.push(file)
-        deployTests.push(file)
+        devTests.add(file)
+        prodTests.add(file)
+        deployTests.add(file)
       } else if (file.startsWith('test/integration/')) {
-        devTests.push(file)
-        prodTests.push(file)
+        devTests.add(file)
+        prodTests.add(file)
       } else if (file.startsWith('test/prod')) {
-        prodTests.push(file)
+        prodTests.add(file)
       } else if (file.startsWith('test/development')) {
-        devTests.push(file)
+        devTests.add(file)
       }
     }
   }
 
-  console.log(
-    'Detected tests:',
-    JSON.stringify(
-      {
-        devTests,
-        prodTests,
-        deployTests,
-      },
-      null,
-      2
+  const externalTestsFilterPaths = getExternalTestsFilterPaths()
+
+  if (
+    externalTestsFilterPaths.some(({ repoRelativePath }) =>
+      changedFiles.includes(repoRelativePath)
     )
-  )
+  ) {
+    const currentManifest = mergeManifests(
+      await Promise.all(
+        externalTestsFilterPaths.map(async ({ absolutePath }) =>
+          JSON.parse(await fs.readFile(absolutePath, 'utf8'))
+        )
+      )
+    )
+    const previousManifest = mergeManifests(
+      (
+        await Promise.all(
+          externalTestsFilterPaths.map(async ({ repoRelativePath }) => {
+            const previousManifestOutput = await execa(
+              `git show ${diffRevision}:${repoRelativePath}`,
+              EXECA_OPTS
+            ).catch(() => null)
+
+            return previousManifestOutput?.stdout
+              ? JSON.parse(previousManifestOutput.stdout)
+              : null
+          })
+        )
+      ).filter(Boolean)
+    )
+
+    for (const file of getDeployManifestChangedTests(
+      currentManifest,
+      previousManifest
+    )) {
+      deployTests.add(file)
+    }
+  }
+
+  const detectedTests = {
+    devTests: [...devTests],
+    prodTests: [...prodTests],
+    deployTests: [...deployTests],
+  }
+
+  console.log('Detected tests:', JSON.stringify(detectedTests, null, 2))
 
-  return { devTests, prodTests, deployTests, commitSha }
+  return { ...detectedTests, commitSha }
 }

filename: test/deploy-tests-manifest.json
patch: @@ -90,7 +90,8 @@
     "test/e2e/app-dir/resume-data-cache/resume-data-cache.test.ts": {
       "failed": [
         "resume-data-cache should have consistent data between static and dynamic renders with fetch cache",
-        "resume-data-cache should have consistent data between static and dynamic renders with use cache"
+        "resume-data-cache should have consistent data between static and dynamic renders with use cache",
+        "resume-data-cache should use RDC for server action re-renders"
       ]
     }
   },

filename: test/get-test-filter.js
patch: @@ -1,57 +1,54 @@
 const path = require('path')
 const minimatch = require('minimatch')
 
+function mergeManifests(manifests) {
+  return manifests.reduce((mergedManifest, manifest) => {
+    if (!mergedManifest) {
+      return structuredClone(manifest)
+    }
+
+    if (manifest.version === 2) {
+      for (const suite in manifest.suites) {
+        if (mergedManifest.suites[suite]) {
+          const mergedSuite = mergedManifest.suites[suite]
+          const currentSuite = manifest.suites[suite]
+          mergedSuite.failed = [
+            ...(mergedSuite.failed || []),
+            ...(currentSuite.failed || []),
+          ]
+          mergedSuite.flakey = [
+            ...(mergedSuite.flakey || []),
+            ...(currentSuite.flakey || []),
+          ]
+        } else {
+          mergedManifest.suites[suite] = structuredClone(manifest.suites[suite])
+        }
+      }
+      mergedManifest.rules.include.push(...(manifest.rules.include || []))
+      mergedManifest.rules.exclude.push(...(manifest.rules.exclude || []))
+      return mergedManifest
+    }
+
+    throw new Error(`Merging manifests is only supported for version 2`)
+  }, null)
+}
+
 function getManifest() {
   const nextExternalTestFilters = process.env.NEXT_EXTERNAL_TESTS_FILTERS
   if (!nextExternalTestFilters) {
     return null
   }
 
-  return nextExternalTestFilters
-    .split(',')
-    .reduce((mergedManifest, manifestPath) => {
-      const manifest = require(path.resolve(manifestPath))
-      if (!mergedManifest) {
-        return manifest
-      }
-
-      if (manifest.version === 2) {
-        for (const suite in manifest.suites) {
-          if (mergedManifest.suites[suite]) {
-            const mergedSuite = mergedManifest.suites[suite]
-            const currentSuite = manifest.suites[suite]
-            mergedSuite.failed = [
-              ...(mergedSuite.failed || []),
-              ...(currentSuite.failed || []),
-            ]
-            mergedSuite.flakey = [
-              ...(mergedSuite.flakey || []),
-              ...(currentSuite.flakey || []),
-            ]
-          } else {
-            mergedManifest.suites[suite] = manifest.suites[suite]
-          }
-        }
-        mergedManifest.rules.include.push(...(manifest.rules.include || []))
-        mergedManifest.rules.exclude.push(...(manifest.rules.exclude || []))
-        return mergedManifest
-      }
-
-      throw new Error(
-        `Merging manifests is only supported for version 2: ${manifestPath}`
-      )
-    }, null)
+  return mergeManifests(
+    nextExternalTestFilters.split(',').map((manifestPath) => {
+      return require(path.resolve(manifestPath))
+    })
+  )
 }
 
-function getTestFilter() {
-  const manifest = getManifest()
+function getTestFilterFromManifest(manifest) {
   if (!manifest) return null
 
-  console.log(
-    'Filtering tests using manifest:',
-    process.env.NEXT_EXTERNAL_TESTS_FILTERS
-  )
-
   // For the legacy manifest without a version, we assume it's a complete list
   // of all the tests.
   if (!manifest.version || typeof manifest.version !== 'number') {
@@ -134,4 +131,21 @@ function getTestFilter() {
   throw new Error(`Unknown manifest version: ${manifest.version}`)
 }
 
-module.exports = { getTestFilter }
+function getTestFilter() {
+  const manifest = getManifest()
+  if (!manifest) return null
+
+  console.log(
+    'Filtering tests using manifest:',
+    process.env.NEXT_EXTERNAL_TESTS_FILTERS
+  )
+
+  return getTestFilterFromManifest(manifest)
+}
+
+module.exports = {
+  getManifest,
+  getTestFilter,
+  getTestFilterFromManifest,
+  mergeManifests,
+}