vercel/next.js · #99001
Enable AI upgrade for canary
docs/01-app/02-guides/upgrading/agentic-upgrade.mdx5 + / 1 −
@@ -13,7 +13,11 @@ as instructed so the original task can continue with a warning. Tell the user about the finding and ask whether they want to run the upgrade. Continue independent work while waiting. If approved, complete the upgrade. -For `latest`, the CLI pins npm's latest stable Next.js release as the target.+For `latest`, the CLI pins the release from the app's channel: npm's `latest`+dist-tag for stable installations and `canary` for canary installations.++Security upgrades and advisory checks are supported only for stable installations.+Canary installations support `latest` and `future` without advisory assessment. ## 1. Check for duplicates packages/next/src/cli/next-upgrade.ts5 + / 3 −
@@ -2,7 +2,7 @@ import { spawn } from 'child_process' import { cp, mkdir, mkdtemp, readFile, rm, writeFile } from 'fs/promises' import { tmpdir } from 'os' import { dirname, join } from 'path'-import { valid } from 'next/dist/compiled/semver'+import { prerelease, valid } from 'next/dist/compiled/semver' import * as Log from '../build/output/log' import createSpinner from '../build/spinner' import { findDir } from '../lib/find-pages-dir'@@ -351,12 +351,14 @@ export async function spawnNextUpgrade( const references = result.references .map((reference) => `- ${reference}`) .join('\n')+ const releaseKind =+ prerelease(result.targetVersion)?.[0] === 'canary' ? 'canary' : 'stable' const reason = upgradeType === 'security' ? 'the installed version is affected by a published security advisory' : upgradeType === 'latest'- ? 'a newer stable Next.js release is available'- : 'the Future policy applies the latest stable release and adopts its Future Defaults'+ ? `a newer ${releaseKind} Next.js release is available`+ : `the Future policy applies the latest ${releaseKind} release and adopts its Future Defaults` const futureDefaultsPrompt = preparedFutureDefaults.length ? ` ${needsVersionMigration ? 'After completing and verifying the version migration, adopt' : 'Adopt'} these Future Defaults in order:packages/next/src/lib/upgrade/nudge.ts63 + / 63 −
@@ -8,6 +8,7 @@ import * as Log from '../../build/output/log' import type { NextConfigComplete } from '../../server/config-shared' import { getAgentName } from '../../telemetry/agent-name' import { futureDefaults } from './future-defaults'+import type { UpgradeAssessment } from './prepare-upgrade' type SecurityNudgeOptions = { directory: string@@ -128,92 +129,64 @@ async function showNudge( throw error } -async function getLatestUpgrade(- installedVersion: string = process.env.__NEXT_VERSION || 'unknown'-): Promise<{ installedVersion: string; latestVersion: string } | null> {- try {- if (!(await getAgentName())) {- return null- }-- const { getLatestUpgradeVersion } =- require('./prepare-upgrade') as typeof import('./prepare-upgrade')- const latestVersion = await getLatestUpgradeVersion(installedVersion)-- if (!latestVersion) {- return null- }-- return { installedVersion, latestVersion }- } catch {- // A release reminder is best-effort; lookup failures should stay quiet.- return null- }-}- async function nudgeForSecurity( options: SecurityNudgeOptions,- policy: 'security' | 'latest' | 'future'+ policy: 'security' | 'latest' | 'future',+ assessment: UpgradeAssessment ): Promise<boolean> {- let advisory- const version = process.env.__NEXT_VERSION || 'unknown'-- try {- if (!(await getAgentName())) {- return false- }-- // Reuse upgrade's advisory readers only after detecting an agent.- const { getSecurityAdvisory } =- require('./prepare-upgrade') as typeof import('./prepare-upgrade')- advisory = await getSecurityAdvisory(version)- } catch {- Log.warn(- 'Could not check Next.js security advisories. Continuing without an upgrade assessment.'- )- return false- }-- if (!advisory) {+ if (!assessment.affected) { return false }+ const version = process.env.__NEXT_VERSION || 'unknown'+ const { reference, upgrade } = assessment+ const action =+ upgrade.status === 'ready'+ ? `At the end of the task, ask the user whether they want you to run the upgrade using the configured '${policy}' policy in the background from this app's directory: - const { reference } = advisory+\`\`\`+next upgrade --ai+\`\`\``+ : `${upgrade.status === 'unknown' ? 'Upgrade availability could not be checked.' : 'No safe newer target is available for the configured upgrade policy.'} ${upgrade.reason}` await showNudge( options, version, 'security',- `Your version of Next.js is affected by a published security advisory and can be automatically upgraded.+ `Your version of Next.js is affected by a published security advisory. **We strongly recommend you upgrade Next.js.** This command stopped so the reminder you configured is not missed. Retry the same command to continue the original task. -At the end of the task, ask the user whether they want you to run the security upgrade in the background from this app's directory:--\`\`\`-next upgrade --ai-\`\`\`+${action} Reference: ${reference} Note: This security gate is enabled by \`experimental.agenticAutoUpgrade: '${policy}'\`.`,- `Your version of Next.js is affected by a published security advisory. This command is continuing after the reminder you configured.\nReference: ${reference}`,+ `Your version of Next.js is affected by a published security advisory. This command is continuing after the reminder you configured.${upgrade.status === 'ready' ? '' : `\n${action}`}\nReference: ${reference}`, 'SecurityFatalError' ) return true } async function nudgeForLatest( options: SecurityNudgeOptions,- policy: 'latest' | 'future'+ policy: 'latest' | 'future',+ assessment: UpgradeAssessment ): Promise<boolean> {+ if (assessment.upgrade.status !== 'ready') {+ return false+ } const version = process.env.__NEXT_VERSION || 'unknown'- const latest = await getLatestUpgrade(version)- if (!latest) return false-- const { installedVersion, latestVersion } = latest- const reference = 'https://registry.npmjs.org/next/latest'+ const { getLatestUpgradeVersion } =+ require('./prepare-upgrade') as typeof import('./prepare-upgrade')+ const { installedVersion, targetVersion } = assessment.upgrade+ const latestVersion = getLatestUpgradeVersion(installedVersion, targetVersion)+ if (!latestVersion) {+ return false+ }+ const distTag =+ semver.prerelease(latestVersion)?.[0] === 'canary' ? 'canary' : 'latest'+ const reference = `https://registry.npmjs.org/next/${distTag}` await showNudge( options, version,@@ -247,7 +220,8 @@ export async function getFutureUpgrade( if ( !(await getAgentName()) || !semver.valid(installedVersion) ||- semver.prerelease(installedVersion)+ (semver.prerelease(installedVersion) &&+ semver.prerelease(installedVersion)?.[0] !== 'canary') ) { return null }@@ -315,13 +289,39 @@ export async function nudgeForUpgrade( return } - const options = { directory, distDir: config.distDir, command }- if (await nudgeForSecurity(options, policy)) return+ const version = process.env.__NEXT_VERSION || 'unknown'+ let assessment: UpgradeAssessment+ try {+ if (+ !(await getAgentName()) ||+ !semver.valid(version) ||+ (semver.prerelease(version) &&+ semver.prerelease(version)?.[0] !== 'canary')+ ) {+ return+ }+ const { getUpgradeAssessment } =+ require('./prepare-upgrade') as typeof import('./prepare-upgrade')+ assessment = await getUpgradeAssessment(version, policy)+ } catch {+ Log.warn(+ 'Could not check Next.js security advisories. Continuing without an upgrade assessment.'+ )+ return+ } + const options = { directory, distDir: config.distDir, command }+ if (await nudgeForSecurity(options, policy, assessment)) {+ return+ }+ if (assessment.upgrade.status !== 'ready') {+ return+ } if (policy === 'latest' || policy === 'future') {- if (await nudgeForLatest(options, policy)) return+ if (await nudgeForLatest(options, policy, assessment)) {+ return+ } }- if (policy === 'future') { await nudgeForFuture(options, config) }packages/next/src/lib/upgrade/prepare-upgrade.ts270 + / 178 −
@@ -44,121 +44,212 @@ export async function prepareUpgrade( throw new Error('Could not determine the installed Next.js version.') } - // TODO: Handle prereleases- if (semver.prerelease(installedVersion)) {+ if (semver.prerelease(installedVersion) && !isCanary(installedVersion)) { throw new Error( 'AI upgrades are not available for prerelease versions of Next.js yet.' ) } - if (targetRequest === 'latest' || targetRequest === 'future') {- const url = `${NPM_REGISTRY}next/latest`- const { value } = await fetchJSON(url)- const release = value as { version: string } | null+ const { upgrade } = await getUpgradeAssessment(+ installedVersion,+ targetRequest+ )+ if (upgrade.status === 'blocked' || upgrade.status === 'unknown') {+ throw new Error(upgrade.reason)+ }+ if (upgrade.status !== 'ready' || targetRequest !== 'future') {+ return upgrade+ } - if (- !release ||- !semver.valid(release.version) ||- semver.prerelease(release.version)- ) {- throw new Error('Could not determine the latest stable Next.js version.')+ const config = await loadConfig(PHASE_INFO, directory, {+ silent: true,+ }).finally(resetEnv)+ const pendingFutureDefaults = futureDefaults.filter(+ (futureDefault) =>+ semver.gte(upgrade.targetVersion, futureDefault.availableSince) &&+ !futureDefault.isAdopted(config)+ )++ if (+ upgrade.targetVersion === installedVersion &&+ pendingFutureDefaults.length === 0+ ) {+ return {+ status: 'unaffected',+ reason: `Next.js ${installedVersion} is current and all available Future Defaults are enabled.`, }+ } - const targetVersion =- targetRequest === 'future' && semver.gt(installedVersion, release.version)- ? installedVersion- : release.version+ return { ...upgrade, futureDefaults: pendingFutureDefaults }+} - if (- targetRequest === 'latest' &&- semver.eq(release.version, installedVersion)- ) {- return {- status: 'unaffected',- reason: `Next.js ${installedVersion} is already the latest stable release.`,- }+export type UpgradeAssessment = {+ // null means advisory assessment is unsupported for this release channel.+ affected: boolean | null+ reference: string | null+ upgrade:+ | UpgradePreparation+ | { status: 'blocked'; reason: string }+ | { status: 'unknown'; reason: string }+}++// TODO: Cache assessments briefly by installed version, policy, and resolved+// target to avoid repeating nudge lookups. New advisories can affect the same+// target, so expire cached results and refresh metadata before execution.+export async function getUpgradeAssessment(+ installedVersion: string,+ policy: 'security' | 'latest' | 'future'+): Promise<UpgradeAssessment> {+ if (!semver.valid(installedVersion)) {+ throw new Error('The running Next.js version is not valid semver.')+ }+ if (semver.prerelease(installedVersion) && !isCanary(installedVersion)) {+ throw new Error(+ 'AI upgrades are not available for prerelease versions of Next.js yet.'+ )+ }+ const canary = isCanary(installedVersion)+ if (canary && policy === 'security') {+ return {+ affected: null,+ reference: null,+ upgrade: {+ status: 'blocked',+ reason:+ 'Security upgrades are not supported for canary versions of Next.js. Use --ai=latest or --ai=future to upgrade on the canary channel.',+ },+ }+ }+ // Canary version ordering does not establish which security fixes it contains.+ // Keep canary latest/Future upgrades independent of advisory assessment.+ const snapshot = canary ? null : await readAdvisorySnapshot(installedVersion)+ const affected = snapshot+ ? snapshot.ranges.some((range) => semver.satisfies(installedVersion, range))+ : null+ const assessment = { affected, reference: snapshot?.reference ?? null }++ if (snapshot && snapshot.targetError !== null) {+ return {+ ...assessment,+ upgrade: { status: 'unknown', reason: snapshot.targetError }, }+ } - if (- targetRequest === 'latest' &&- semver.lt(release.version, installedVersion)- ) {- return {+ if (policy === 'security' && !affected) {+ return {+ ...assessment,+ upgrade: { status: 'unaffected',- reason: `Next.js ${installedVersion} is newer than the latest stable release ${release.version}.`,- }+ reason: `No security update is needed for Next.js ${installedVersion}.`,+ }, }+ } - let pendingFutureDefaults: FutureDefaultEntry[] = []-- if (targetRequest === 'future') {- const securitySnapshot = await readSecuritySnapshot(targetVersion)+ // Keep a confirmed advisory even when target metadata cannot be read.+ try {+ let targetVersion: string+ let references: string[]+ if (policy === 'security' && snapshot) {+ const registryURL = `${NPM_REGISTRY}next`+ const registry = snapshot.registry ?? (await fetchJSON(registryURL)).value+ const releases = parseReleases(registry)+ references = [snapshot.reference, registryURL]+ try {+ targetVersion = selectSecurityTarget(installedVersion, {+ ranges: snapshot.ranges,+ releases,+ references,+ })!.version+ } catch (error) {+ return {+ ...assessment,+ upgrade: { status: 'blocked', reason: (error as Error).message },+ }+ }+ } else {+ const release = await fetchLatestRelease(installedVersion)+ if (!release) {+ throw new Error(+ canary+ ? 'Could not determine the latest Next.js version on the canary dist-tag.'+ : 'Could not determine the latest stable Next.js version.'+ )+ }+ targetVersion =+ policy === 'future' && semver.gt(installedVersion, release.version)+ ? installedVersion+ : release.version+ references = [release.reference]+ const releaseKind = canary ? 'canary release' : 'stable release'++ if (policy === 'latest' && semver.lt(targetVersion, installedVersion)) {+ return {+ ...assessment,+ upgrade: {+ status: 'unaffected',+ reason: `Next.js ${installedVersion} is newer than the latest ${releaseKind} ${targetVersion}.`,+ },+ }+ } + // The tag can advance after npm's published-version snapshot was read.+ // Include that exact target in the fallback query as well. if (- securitySnapshot?.ranges.some((range) =>- semver.satisfies(targetVersion, range)+ snapshot &&+ snapshot.registry !== null &&+ !advisoryVersions(snapshot.registry, installedVersion).includes(+ targetVersion ) ) {- throw new Error(- `Next.js ${targetVersion} is affected by an active advisory.`+ snapshot.ranges = affectedRanges(+ await readNpmAdvisories([+ ...advisoryVersions(snapshot.registry, installedVersion),+ targetVersion,+ ])+ )+ assessment.affected ||= snapshot.ranges.some((range) =>+ semver.satisfies(installedVersion, range) ) }-- const config = await loadConfig(PHASE_INFO, directory, {- silent: true,- }).finally(resetEnv)-- pendingFutureDefaults = futureDefaults.filter(- (futureDefault) =>- semver.gte(targetVersion, futureDefault.availableSince) &&- !futureDefault.isAdopted(config)- )- if (- targetVersion === installedVersion &&- pendingFutureDefaults.length === 0+ snapshot?.ranges.some((range) => semver.satisfies(targetVersion, range)) ) { return {- status: 'unaffected',- reason: `Next.js ${installedVersion} is current and all available Future Defaults are enabled.`,+ ...assessment,+ upgrade: {+ status: 'blocked',+ reason: `Next.js ${targetVersion} is affected by an active advisory.`,+ }, } }- } - return {- status: 'ready',- installedVersion,- targetVersion,- references: [url],- futureDefaults: pendingFutureDefaults,+ if (policy === 'latest' && semver.eq(targetVersion, installedVersion)) {+ return {+ ...assessment,+ upgrade: {+ status: 'unaffected',+ reason: `Next.js ${installedVersion} is already the latest ${releaseKind}.`,+ },+ }+ } }- }-- const snapshot = await readSecuritySnapshot(installedVersion) - if (!snapshot) { return {- status: 'unaffected',- reason: `No security update is needed for Next.js ${installedVersion}.`,+ ...assessment,+ upgrade: {+ status: 'ready',+ installedVersion,+ targetVersion,+ references,+ futureDefaults: [],+ }, }- }-- const selected = selectSecurityTarget(installedVersion, snapshot)-- if (!selected) {+ } catch (error) { return {- status: 'unaffected',- reason: `No security update is needed for Next.js ${installedVersion}.`,+ ...assessment,+ upgrade: { status: 'unknown', reason: (error as Error).message }, } }-- return {- status: 'ready',- installedVersion,- targetVersion: selected.version,- references: snapshot.references,- futureDefaults: [],- } } type Advisory = {@@ -208,6 +299,10 @@ async function fetchJSON( } } +function isCanary(version: string): boolean {+ return semver.prerelease(version)?.[0] === 'canary'+}+ function parseReleases(value: unknown): SecuritySnapshot['releases'] { const data = value as { versions:@@ -289,68 +384,50 @@ function affectedRanges(advisories: Advisory[]): string[] { return ranges } -export async function getLatestUpgradeVersion(version: string) {- // TODO: Support prerelease upgrade policies once their target selection is- // defined for explicit upgrades and background reminders.- if (!semver.valid(version) || semver.prerelease(version)) {- return null- }-- const { value } = await fetchJSON(`${NPM_REGISTRY}next/latest`)- const release = value as { version: string } | null-+export function getLatestUpgradeVersion(+ version: string,+ targetVersion: string+) { if (- !release ||- !semver.valid(release.version) ||- semver.prerelease(release.version) !== null ||- !semver.gt(release.version, version)+ !semver.valid(version) ||+ !semver.valid(targetVersion) ||+ (semver.prerelease(version) && !isCanary(version)) ||+ (isCanary(version)+ ? !isCanary(targetVersion)+ : semver.prerelease(targetVersion)) ||+ !semver.gt(targetVersion, version) ) { return null }-- // Patch releases remain available to explicit upgrades without a reminder.+ // Patches and consecutive canaries remain available to explicit upgrades+ // without a reminder. if (- semver.major(release.version) === semver.major(version) &&- semver.minor(release.version) === semver.minor(version)+ semver.major(targetVersion) === semver.major(version) &&+ semver.minor(targetVersion) === semver.minor(version) ) { return null }-- return release.version+ return targetVersion } -// Count only advisories affecting the running version for the startup prompt.-// Full release selection remains in the explicit upgrade command.-export async function getSecurityAdvisory(version: string) {- if (!semver.valid(version)) {- throw new Error('The running Next.js version is not valid semver.')- }-- if (semver.prerelease(version)) {- return null- }-- let advisories: Advisory[]- let reference: string-- try {- const result = await readGitHubAdvisories(version)- advisories = result.advisories- reference = result.reference- } catch {- advisories = await readNpmAdvisories([version])- reference = NPM_ADVISORIES- }+async function fetchLatestRelease(installedVersion: string): Promise<{+ version: string+ reference: string+} | null> {+ const canary = isCanary(installedVersion)+ const reference = `${NPM_REGISTRY}next/${canary ? 'canary' : 'latest'}`+ const { value } = await fetchJSON(reference)+ const release = value as { version: string } | null if (- !affectedRanges(advisories).some((range) =>- semver.satisfies(version, range)- )+ !release ||+ !semver.valid(release.version) ||+ (canary ? !isCanary(release.version) : semver.prerelease(release.version)) ) { return null } - return { reference }+ return { version: release.version, reference } } async function readGitHubAdvisories(version: string | null) {@@ -446,64 +523,80 @@ async function readNpmAdvisories(versions: string[]): Promise<Advisory[]> { return advisories } -// TODO: Replace provider-specific requests with a Next.js-maintained endpoint-// that returns advisory ranges and exact safe targets for each major.-async function readSecuritySnapshot(- installedVersion: string-): Promise<SecuritySnapshot | undefined> {- // Accept GitHub evidence only after every page succeeds. On failure, npm- // replaces the entire advisory set rather than supplementing partial results.- let githubRanges: string[] | undefined- let githubFailure: unknown-- try {- githubRanges = affectedRanges((await readGitHubAdvisories(null)).advisories)-- if (- !githubRanges.some((range) => semver.satisfies(installedVersion, range))- ) {- return- }- } catch (error) {- githubFailure = error+function advisoryVersions(value: unknown, checkedVersion: string): string[] {+ const data = value as { versions: Record<string, unknown> | undefined }+ if (+ !data?.versions ||+ typeof data.versions !== 'object' ||+ Array.isArray(data.versions)+ ) {+ throw new Error('Could not check for security updates.') } - const registryURL = `${NPM_REGISTRY}next`- let releases: SecuritySnapshot['releases']- let ranges: string[]- let advisoryReference: string+ // Include all published versions to retrieve advisories affecting candidates,+ // not only the installed version.+ const versions = Object.keys(data.versions).filter((version) =>+ semver.valid(version)+ )+ if (!versions.includes(checkedVersion)) {+ versions.push(checkedVersion)+ }+ return versions+} +// TODO: Replace provider-specific requests with a Next.js-maintained endpoint+// that returns advisory ranges and exact safe targets for each major.+async function readAdvisorySnapshot(installedVersion: string): Promise<{+ ranges: string[]+ reference: string+ registry: unknown | null+ targetError: string | null+}> {+ // Accept GitHub evidence only after every page and range validates. On+ // failure, npm replaces the entire set, including candidate advisories. try {- const { value } = await fetchJSON(registryURL)- releases = parseReleases(value)-- if (githubRanges) {- ranges = githubRanges- advisoryReference = ADVISORIES- } else {- // Query every published version, including prereleases: querying only the- // installed version could miss advisories affecting a candidate target.- const versions = Object.keys(- (value as { versions: Record<string, unknown> }).versions- ).filter((version) => semver.valid(version))- ranges = affectedRanges(await readNpmAdvisories(versions))- advisoryReference = NPM_ADVISORIES+ const { advisories, reference } = await readGitHubAdvisories(null)+ return {+ ranges: affectedRanges(advisories),+ reference,+ registry: null,+ targetError: null, }- } catch (error) {- if (!githubRanges) {+ } catch (githubFailure) {+ try {+ const { value } = await fetchJSON(`${NPM_REGISTRY}next`)+ const ranges = affectedRanges(+ await readNpmAdvisories(advisoryVersions(value, installedVersion))+ )+ return {+ ranges,+ reference: NPM_ADVISORIES,+ registry: value,+ targetError: null,+ }+ } catch (error) {+ // A source-only response can still establish a warning, but cannot+ // establish target safety.+ try {+ const ranges = affectedRanges(+ await readNpmAdvisories([installedVersion])+ )+ if (ranges.some((range) => semver.satisfies(installedVersion, range))) {+ return {+ ranges,+ reference: NPM_ADVISORIES,+ registry: null,+ targetError: 'Could not assess upgrade targets. Please try again.',+ }+ }+ } catch {+ // Neither provider could establish even a source-only warning.+ } throw new Error( 'Could not check for security updates. Please try again.', { cause: [githubFailure, error] } ) }-- throw error- }-- return {- ranges,- releases,- references: [advisoryReference, registryURL], } } @@ -518,8 +611,7 @@ function selectSecurityTarget( } const releases = snapshot.releases- // Consider only the latest stable release of each major, not an older patch- // that happens to be safe while that major's latest release is affected.+ // Consider only the latest stable release of each major, not an older safe patch. const latest = new Map<number, PackageRelease>() for (const release of releases) {test/unit/agentic-upgrade-prompts.test.ts34 + / 0 −
@@ -547,6 +547,40 @@ describe('agentic upgrade prompts', () => { `) }) + it.each(['latest', 'future'] as const)(+ 'hands off the exact canary target for %s upgrades',+ async (policy) => {+ jest.mocked(prepareUpgrade).mockResolvedValue({+ status: 'ready',+ installedVersion: '17.2.0-canary.4',+ targetVersion: '17.2.0-canary.9',+ references: ['https://registry.npmjs.org/next/canary'],+ futureDefaults: [],+ })++ await spawnNextUpgrade('/workspace/app', {+ revision: 'latest',+ verbose: false,+ ai: policy,+ })++ expect(prepareUpgrade).toHaveBeenCalledWith('/workspace/app', policy)+ const prompt = normalizedBootstrapCalls().flat().join('\n')+ expect(prompt).toContain(+ 'from Next.js 17.2.0-canary.4 to 17.2.0-canary.9'+ )+ expect(prompt).toContain(+ policy === 'latest'+ ? 'newer canary Next.js release'+ : 'latest canary release'+ )+ expect(prompt).toContain('https://registry.npmjs.org/next/canary')+ expect(String(jest.mocked(writeFile).mock.calls[0][1])).toContain(+ 'upgrade 17.2.0-canary.9 --yes --skip-adoption'+ )+ }+ )+ it('adds temporary Future Default instructions to the migration prompt', async () => { jest.mocked(prepareUpgrade).mockResolvedValue({ status: 'ready',test/unit/prepare-latest-upgrade.test.ts551 + / 12 −
@@ -1,7 +1,12 @@ import { mkdtemp, mkdir, rm, writeFile } from 'fs/promises' import { tmpdir } from 'os' import { join } from 'path'-import { prepareUpgrade } from 'next/dist/lib/upgrade/prepare-upgrade'+import semver from 'next/dist/compiled/semver'+import {+ getLatestUpgradeVersion,+ getUpgradeAssessment,+ prepareUpgrade,+} from 'next/dist/lib/upgrade/prepare-upgrade' import loadConfig from 'next/dist/server/config' jest.mock('next/dist/server/config', () => ({@@ -26,14 +31,16 @@ describe('prepare latest upgrade', () => { } function mockLatestVersion(version: string) {- global.fetch = jest.fn().mockResolvedValue(- new Response(- JSON.stringify({- version,- engines: { node: '>=18' },- }),- { status: 200 }- )+ global.fetch = jest.fn(async (input) =>+ String(input).startsWith('https://api.github.com/')+ ? Response.json([])+ : new Response(+ JSON.stringify({+ version,+ engines: { node: '>=18' },+ }),+ { status: 200 }+ ) ) } @@ -86,6 +93,287 @@ describe('prepare latest upgrade', () => { } as never) }) + function mockSecurityMetadata({+ ranges = ['>=17.2.0-canary.0 <17.2.0-canary.5'],+ target = '17.2.0-canary.5' as string | null,+ published = ['17.2.0', '17.2.0-canary.5', '17.3.0-canary.0'],+ fallback = false,+ npmFailure = false,+ } = {}) {+ global.fetch = jest.fn(async (input, init) => {+ const url = String(input)+ if (url.startsWith('https://api.github.com/advisories?')) {+ return fallback+ ? new Response(null, { status: 500 })+ : Response.json(+ ranges.map((range) => ({+ withdrawn_at: null,+ vulnerabilities: [+ {+ package: { ecosystem: 'npm', name: 'next' },+ vulnerable_version_range: range,+ },+ ],+ }))+ )+ }+ if (url === 'https://registry.npmjs.org/next') {+ return Response.json({+ 'dist-tags': target === null ? {} : { canary: target },+ versions: Object.fromEntries(+ published.map((version) => [version, { version }])+ ),+ })+ }+ if (+ url === 'https://registry.npmjs.org/next/canary' ||+ url === 'https://registry.npmjs.org/next/latest'+ ) {+ return Response.json({ version: target })+ }+ if (+ url === 'https://registry.npmjs.org/-/npm/v1/security/advisories/bulk'+ ) {+ if (npmFailure) {+ return new Response(null, { status: 500 })+ }+ const { next: versions } = JSON.parse(String(init?.body)) as {+ next: string[]+ }+ // Model npm's default prerelease exclusion instead of returning every+ // advisory regardless of the versions submitted in the request.+ return Response.json({+ next: ranges+ .filter((range) =>+ versions.some((version) => semver.satisfies(version, range))+ )+ .map((range) => ({ vulnerable_versions: range })),+ })+ }+ throw new Error(`Unexpected request: ${url}`)+ })+ }++ it('rejects canary security upgrades without querying advisories or releases', async () => {+ const directory = await createApp('17.2.0-canary.4')+ global.fetch = jest.fn()+ await expect(+ getUpgradeAssessment('17.2.0-canary.4', 'security')+ ).resolves.toMatchObject({+ affected: null,+ reference: null,+ upgrade: {+ status: 'blocked',+ reason: expect.stringContaining(+ 'Security upgrades are not supported for canary'+ ),+ },+ })+ await expect(prepareUpgrade(directory, 'security')).rejects.toThrow(+ 'Security upgrades are not supported for canary'+ )+ expect(global.fetch).toHaveBeenCalledTimes(0)+ })++ it('does not treat failed stable advisory providers as an unaffected version', async () => {+ const directory = await createApp('17.2.0')+ mockSecurityMetadata({ fallback: true, npmFailure: true })+ await expect(getUpgradeAssessment('17.2.0', 'security')).rejects.toThrow()+ await expect(prepareUpgrade(directory, 'security')).rejects.toThrow(+ 'Could not check for security updates.'+ )+ })++ it.each(['17.2.0', '17.1.9'])(+ 'blocks a stable security upgrade without a newer safe target: %s',+ async (target) => {+ const directory = await createApp('17.2.0')+ mockSecurityMetadata({ ranges: ['17.2.0'], published: [target] })+ await expect(+ getUpgradeAssessment('17.2.0', 'security')+ ).resolves.toMatchObject({+ affected: true,+ upgrade: { status: 'blocked' },+ })+ await expect(prepareUpgrade(directory, 'security')).rejects.toThrow(+ 'No safe Next.js update is currently available.'+ )+ }+ )++ describe('shared stable eligibility', () => {+ const installed = '17.2.0'+ it.each(['security', 'latest', 'future'] as const)(+ 'uses the same safe target in the %s nudge and command',+ async (policy) => {+ const directory = await createApp(installed)+ const target = '17.3.0'+ mockSecurityMetadata({+ ranges: ['>=17.0.0 <17.2.1'],+ target,+ published: [installed, target],+ })+ const advisory = await getUpgradeAssessment(installed, policy)+ expect(advisory).toMatchObject({+ upgrade: { status: 'ready', targetVersion: target },+ })+ expect(await prepareUpgrade(directory, policy)).toMatchObject({+ status: 'ready',+ targetVersion: target,+ })+ }+ )++ it.each(['security', 'latest', 'future'] as const)(+ 'retains the advisory when the %s target metadata cannot be fetched',+ async (policy) => {+ const directory = await createApp(installed)+ mockSecurityMetadata({ ranges: ['>=17.0.0 <17.2.1'] })+ const fetchMetadata = global.fetch+ global.fetch = jest.fn(async (input, init) => {+ if (String(input).startsWith('https://registry.npmjs.org/next')) {+ return new Response(null, { status: 503 })+ }+ return fetchMetadata(input, init)+ })+ await expect(+ getUpgradeAssessment(installed, policy)+ ).resolves.toMatchObject({+ upgrade: {+ status: 'unknown',+ reason: expect.stringContaining('Could not fetch upgrade metadata'),+ },+ })+ await expect(prepareUpgrade(directory, policy)).rejects.toThrow(+ 'Could not fetch upgrade metadata'+ )+ }+ )++ it.each(['latest', 'future'] as const)(+ 'checks the %s target even when the installed version is unaffected',+ async (policy) => {+ const directory = await createApp(installed)+ const target = '17.3.0'+ mockSecurityMetadata({+ ranges: [target],+ target,+ published: [installed, target],+ })+ await expect(+ getUpgradeAssessment(installed, policy)+ ).resolves.toMatchObject({+ affected: false,+ upgrade: { status: 'blocked' },+ })+ await expect(prepareUpgrade(directory, policy)).rejects.toThrow(+ `${target} is affected by an active advisory`+ )+ }+ )++ it.each(['security', 'latest', 'future'] as const)(+ 'retains npm advisory warnings without release metadata for %s',+ async (policy) => {+ const directory = await createApp(installed)+ mockSecurityMetadata({ fallback: true, ranges: [installed] })+ const fetchMetadata = global.fetch+ global.fetch = jest.fn(async (input, init) => {+ if (String(input).startsWith('https://registry.npmjs.org/next')) {+ return new Response(null, { status: 503 })+ }+ return fetchMetadata(input, init)+ })+ await expect(+ getUpgradeAssessment(installed, policy)+ ).resolves.toMatchObject({+ affected: true,+ reference:+ 'https://registry.npmjs.org/-/npm/v1/security/advisories/bulk',+ upgrade: { status: 'unknown' },+ })+ await expect(prepareUpgrade(directory, policy)).rejects.toThrow(+ 'Could not assess upgrade targets.'+ )+ }+ )+ })++ it('includes a newly published tag in npm fallback assessment', async () => {+ const target = '17.3.0'+ const directory = await createApp('17.2.0')+ mockSecurityMetadata({+ fallback: true,+ ranges: [target],+ target,+ published: ['17.2.0'],+ })+ await expect(+ getUpgradeAssessment('17.2.0', 'latest')+ ).resolves.toMatchObject({+ affected: false,+ upgrade: {+ status: 'blocked',+ reason: `Next.js ${target} is affected by an active advisory.`,+ },+ })+ await expect(prepareUpgrade(directory, 'latest')).rejects.toThrow(+ 'affected by an active advisory'+ )+ })++ it.each([false, true])(+ 'assesses the configured policy rather than a different safe security target, fallback=%s',+ async (fallback) => {+ const directory = await createApp('17.2.0')+ mockSecurityMetadata({+ fallback,+ ranges: ['17.2.0', '17.3.0'],+ target: '17.3.0',+ published: ['17.2.0', '17.3.0', '18.0.0'],+ })+ await expect(+ getUpgradeAssessment('17.2.0', 'security')+ ).resolves.toMatchObject({+ upgrade: { status: 'ready', targetVersion: '18.0.0' },+ })+ for (const policy of ['latest', 'future'] as const) {+ await expect(+ getUpgradeAssessment('17.2.0', policy)+ ).resolves.toMatchObject({ upgrade: { status: 'blocked' } })+ await expect(prepareUpgrade(directory, policy)).rejects.toThrow(+ '17.3.0 is affected by an active advisory'+ )+ }+ }+ )++ it('preserves stable security target selection', async () => {+ const directory = await createApp('17.2.0')+ mockSecurityMetadata({+ ranges: ['>=17.0.0 <17.2.1'],+ published: ['17.2.0', '17.2.1', '18.0.0', '17.3.0-canary.0'],+ })+ await expect(prepareUpgrade(directory, 'security')).resolves.toEqual(+ expect.objectContaining({ targetVersion: '17.2.1' })+ )+ })++ it.each(['17.2.0-rc.1', '17.2.0-beta.1'])(+ 'keeps other prerelease channels unsupported: %s',+ async (version) => {+ const directory = await createApp(version)+ mockSecurityMetadata()+ await expect(prepareUpgrade(directory, 'security')).rejects.toThrow(+ 'AI upgrades are not available for prerelease versions'+ )+ await expect(getUpgradeAssessment(version, 'security')).rejects.toThrow(+ 'prerelease versions'+ )+ expect(global.fetch).toHaveBeenCalledTimes(0)+ }+ )+ afterEach(async () => { global.fetch = originalFetch await Promise.all(@@ -95,6 +383,154 @@ describe('prepare latest upgrade', () => { ) }) + describe.each(['latest', 'future'] as const)(+ '%s canary upgrades',+ (policy) => {+ it.each([+ '17.2.0-canary.5',+ '17.2.1-canary.0',+ '17.3.0-canary.0',+ '18.0.0-canary.0',+ ])(+ 'selects the exact canary tag %s for an explicit upgrade',+ async (target) => {+ const directory = await createApp('17.2.0-canary.4')+ mockSecurityMetadata({ target, ranges: [] })+ await expect(prepareUpgrade(directory, policy)).resolves.toEqual(+ expect.objectContaining({+ status: 'ready',+ targetVersion: target,+ references: ['https://registry.npmjs.org/next/canary'],+ })+ )+ expect(global.fetch).toHaveBeenCalledWith(+ 'https://registry.npmjs.org/next/canary',+ expect.any(Object)+ )+ }+ )++ it.each(['17.2.0-canary.4', '17.2.0-canary.3'])(+ 'does not change versions when the tag is equal or older: %s',+ async (target) => {+ const directory = await createApp('17.2.0-canary.4')+ jest+ .mocked(loadConfig)+ .mockResolvedValue({ cacheComponents: true } as never)+ mockSecurityMetadata({ target, ranges: [] })+ await expect(prepareUpgrade(directory, policy)).resolves.toEqual(+ expect.objectContaining({ status: 'unaffected' })+ )+ }+ )++ it.each([null, 'invalid', '17.3.0', '17.3.0-rc.1'])(+ 'rejects invalid or wrong-channel tag metadata: %s',+ async (target) => {+ const directory = await createApp('17.2.0-canary.4')+ mockSecurityMetadata({ target })+ await expect(prepareUpgrade(directory, policy)).rejects.toThrow(+ 'Could not determine the latest Next.js version on the canary dist-tag.'+ )+ }+ )++ it.each(['17.2.0-rc.1', '17.2.0-beta.1', '17.2.0-preview.1'])(+ 'keeps other prereleases unsupported: %s',+ async (installed) => {+ const directory = await createApp(installed)+ mockSecurityMetadata()+ await expect(prepareUpgrade(directory, policy)).rejects.toThrow(+ 'AI upgrades are not available for prerelease versions'+ )+ expect(global.fetch).toHaveBeenCalledTimes(0)+ }+ )+ }+ )++ it.each(['latest', 'future'] as const)(+ 'runs canary %s without advisory assessment even when providers are unavailable',+ async (policy) => {+ const directory = await createApp('17.2.0-canary.4')+ global.fetch = jest.fn(async (input) => {+ if (String(input) === 'https://registry.npmjs.org/next/canary') {+ return Response.json({ version: '17.2.0-canary.5' })+ }+ throw new Error('Advisory lookup should not run for canaries')+ })+ await expect(+ getUpgradeAssessment('17.2.0-canary.4', policy)+ ).resolves.toMatchObject({+ affected: null,+ reference: null,+ upgrade: { status: 'ready', targetVersion: '17.2.0-canary.5' },+ })+ await expect(prepareUpgrade(directory, policy)).resolves.toMatchObject({+ status: 'ready',+ targetVersion: '17.2.0-canary.5',+ })+ expect(+ jest.mocked(global.fetch).mock.calls.map(([url]) => String(url))+ ).toEqual([+ 'https://registry.npmjs.org/next/canary',+ 'https://registry.npmjs.org/next/canary',+ ])+ }+ )++ it.each(['17.2.0-canary.4', '17.2.0-canary.3'])(+ 'adopts available defaults without a version change when the tag is %s',+ async (target) => {+ const directory = await createApp('17.2.0-canary.4')+ mockSecurityMetadata({ target, ranges: [] })+ await expect(prepareUpgrade(directory, 'future')).resolves.toEqual(+ expect.objectContaining({+ status: 'ready',+ targetVersion: '17.2.0-canary.4',+ futureDefaults: [+ expect.objectContaining({ name: 'Cache Components' }),+ ],+ })+ )+ }+ )++ it('keeps the stable Future availability boundary for a same-base canary', async () => {+ const directory = await createApp('16.3.0-canary.1')+ mockSecurityMetadata({ target: '16.3.0-canary.1', ranges: [] })+ await expect(prepareUpgrade(directory, 'future')).resolves.toEqual(+ expect.objectContaining({ status: 'unaffected' })+ )+ })++ it.each([false, true])(+ 'assesses a retained Future version missing from registry metadata with npm fallback=%s',+ async (fallback) => {+ const directory = await createApp('17.2.9')+ mockSecurityMetadata({+ fallback,+ ranges: ['17.2.9'],+ target: '17.2.1',+ published: ['17.2.0', '17.2.1'],+ })+ await expect(prepareUpgrade(directory, 'future')).rejects.toThrow(+ '17.2.9 is affected by an active advisory.'+ )+ }+ )++ it.each(['latest', 'future'] as const)(+ 'fails a %s assessment when both advisory providers are unavailable',+ async (policy) => {+ const directory = await createApp('17.2.0')+ mockSecurityMetadata({ fallback: true, npmFailure: true })+ await expect(prepareUpgrade(directory, policy)).rejects.toThrow(+ 'Could not check for security updates.'+ )+ }+ )+ it('selects the exact latest stable release', async () => { const directory = await createApp('16.2.1') mockLatestVersion('17.1.0')@@ -107,10 +543,65 @@ describe('prepare latest upgrade', () => { references: ['https://registry.npmjs.org/next/latest'], }) )- expect(global.fetch).toHaveBeenCalledTimes(1)- expect(jest.mocked(global.fetch).mock.calls[0][0]).toBe(- 'https://registry.npmjs.org/next/latest'+ expect(global.fetch).toHaveBeenCalledTimes(2)+ expect(global.fetch).toHaveBeenCalledWith(+ 'https://registry.npmjs.org/next/latest',+ expect.any(Object)+ )+ })++ it('allows an explicit patch upgrade without a reminder', async () => {+ const directory = await createApp('17.1.0')+ mockLatestVersion('17.1.1')++ await expect(prepareUpgrade(directory, 'latest')).resolves.toEqual(+ expect.objectContaining({ status: 'ready', targetVersion: '17.1.1' }) )+ expect(getLatestUpgradeVersion('17.1.0', '17.1.1')).toBeNull()+ })++ it.each([null, {}, { version: 'invalid' }, { version: '17.2.0-canary.1' }])(+ 'reports invalid metadata for explicit upgrades but suppresses reminders: %j',+ async (metadata) => {+ const directory = await createApp('17.1.0')+ global.fetch = jest.fn(async (input) =>+ Response.json(+ String(input).startsWith('https://api.github.com/') ? [] : metadata+ )+ )++ await expect(prepareUpgrade(directory, 'latest')).rejects.toThrow(+ 'Could not determine the latest stable Next.js version.'+ )+ expect(+ getLatestUpgradeVersion(+ '17.1.0',+ String(metadata && 'version' in metadata ? metadata.version : '')+ )+ ).toBeNull()+ }+ )++ it('preserves target lookup failures in the shared assessment and command', async () => {+ const directory = await createApp('17.1.0')+ global.fetch = jest.fn(async (input) => {+ if (String(input).startsWith('https://api.github.com/')) {+ return Response.json([])+ }+ throw new Error('Network unavailable')+ })++ await expect(prepareUpgrade(directory, 'latest')).rejects.toThrow(+ 'Could not fetch upgrade metadata.'+ )+ await expect(+ getUpgradeAssessment('17.1.0', 'latest')+ ).resolves.toMatchObject({+ upgrade: {+ status: 'unknown',+ reason: expect.stringContaining('Could not fetch upgrade metadata.'),+ },+ }) }) it('does nothing when the app already uses latest', async () => {@@ -133,6 +624,54 @@ describe('prepare latest upgrade', () => { }) }) + it.each([false, true])(+ 'ignores an affected older latest target with npm fallback=%s',+ async (fallback) => {+ const directory = await createApp('17.3.0')+ mockSecurityMetadata({+ fallback,+ ranges: ['17.2.0'],+ target: '17.2.0',+ published: ['17.2.0', '17.3.0'],+ })++ await expect(+ getUpgradeAssessment('17.3.0', 'latest')+ ).resolves.toMatchObject({+ affected: false,+ upgrade: { status: 'unaffected' },+ })+ await expect(prepareUpgrade(directory, 'latest')).resolves.toEqual({+ status: 'unaffected',+ reason:+ 'Next.js 17.3.0 is newer than the latest stable release 17.2.0.',+ })+ }+ )++ it.each([false, true])(+ 'still blocks an affected equal latest target with npm fallback=%s',+ async (fallback) => {+ const directory = await createApp('17.3.0')+ mockSecurityMetadata({+ fallback,+ ranges: ['17.3.0'],+ target: '17.3.0',+ published: ['17.3.0'],+ })++ await expect(+ getUpgradeAssessment('17.3.0', 'latest')+ ).resolves.toMatchObject({+ affected: true,+ upgrade: { status: 'blocked' },+ })+ await expect(prepareUpgrade(directory, 'latest')).rejects.toThrow(+ 'Next.js 17.3.0 is affected by an active advisory.'+ )+ }+ )+ it('allows a Future target outside npm fallback advisory ranges', async () => { const directory = await createApp('16.2.0') mockFutureMetadata('<16.3.0')test/unit/security-upgrade-nudge.test.ts238 + / 40 −
@@ -4,24 +4,39 @@ import { join } from 'path' import { getFutureUpgrade, nudgeForUpgrade } from 'next/dist/lib/upgrade/nudge' import { getAgentName } from 'next/dist/telemetry/agent-name'-import {- getLatestUpgradeVersion,- getSecurityAdvisory,-} from 'next/dist/lib/upgrade/prepare-upgrade'+import { getUpgradeAssessment } from 'next/dist/lib/upgrade/prepare-upgrade' import { warn } from 'next/dist/build/output/log' jest.mock('next/dist/telemetry/agent-name', () => ({ getAgentName: jest.fn(), })) jest.mock('next/dist/lib/upgrade/prepare-upgrade', () => ({- getLatestUpgradeVersion: jest.fn(),- getSecurityAdvisory: jest.fn(),+ getLatestUpgradeVersion: jest.requireActual(+ 'next/dist/lib/upgrade/prepare-upgrade'+ ).getLatestUpgradeVersion,+ getUpgradeAssessment: jest.fn(), })) jest.mock('next/dist/build/output/log', () => ({ warn: jest.fn(), })) +function mockUpgrade(targetVersion = process.env.__NEXT_VERSION || '16.4.0') {+ const canary = process.env.__NEXT_VERSION?.includes('-canary.')+ jest.mocked(getUpgradeAssessment).mockResolvedValue({+ affected: canary ? null : false,+ reference: canary ? null : 'https://api.github.com/advisories?affects=next',+ upgrade: {+ status: 'ready',+ installedVersion: process.env.__NEXT_VERSION || '16.4.0',+ targetVersion,+ references: [],+ futureDefaults: [],+ },+ })+}+ let directory: string+const initialNextVersion = process.env.__NEXT_VERSION const config = ( policy: 'security' | 'latest' | 'future',@@ -34,10 +49,16 @@ const config = ( }) as never beforeEach(async () => {+ process.env.__NEXT_VERSION = '16.4.0' directory = await mkdtemp(join(tmpdir(), 'security-upgrade-nudge-')) }) afterEach(async () => {+ if (initialNextVersion === undefined) {+ delete process.env.__NEXT_VERSION+ } else {+ process.env.__NEXT_VERSION = initialNextVersion+ } await rm(directory, { recursive: true, force: true }) }) @@ -51,7 +72,7 @@ describe('security upgrade nudge', () => { jest.resetAllMocks() process.env.__NEXT_VERSION = '13.0.0' jest.mocked(getAgentName).mockResolvedValue('codex')- jest.mocked(getSecurityAdvisory).mockResolvedValue(null)+ mockUpgrade() }) afterAll(() => {@@ -63,22 +84,38 @@ describe('security upgrade nudge', () => { }) it('shows a security advisory and the upgrade command', async () => {- jest.mocked(getSecurityAdvisory).mockResolvedValue({+ jest.mocked(getUpgradeAssessment).mockResolvedValue({+ affected: true,+ upgrade: {+ status: 'ready',+ installedVersion: '13.0.0',+ targetVersion: '17.2.1',+ references: [],+ futureDefaults: [],+ }, reference: 'https://api.github.com/advisories?affects=next%4013.0.0', }) await expect(run()).rejects.toMatchObject({ name: 'SecurityFatalError', exitCode: 1, message: expect.stringMatching(- /affected by a published security advisory[\s\S]*\*\*We strongly recommend you upgrade Next\.js\.\*\*[\s\S]*reminder you configured is not missed\. Retry the same command to continue the original task\.[\s\S]*At the end of the task, ask the user whether they want you to run the security upgrade in the background from this app's directory:[\s\S]*```\nnext upgrade --ai\n```/+ /affected by a published security advisory[\s\S]*\*\*We strongly recommend you upgrade Next\.js\.\*\*[\s\S]*reminder you configured is not missed\. Retry the same command to continue the original task\.[\s\S]*At the end of the task, ask the user whether they want you to run the upgrade using the configured 'security' policy in the background from this app's directory:[\s\S]*```\nnext upgrade --ai\n```/ ), }) expect(warn).not.toHaveBeenCalled() }) it('shows the npm fallback reference', async () => {- jest.mocked(getSecurityAdvisory).mockResolvedValue({+ jest.mocked(getUpgradeAssessment).mockResolvedValue({+ affected: true,+ upgrade: {+ status: 'ready',+ installedVersion: '13.0.0',+ targetVersion: '17.2.1',+ references: [],+ futureDefaults: [],+ }, reference: 'https://registry.npmjs.org/-/npm/v1/security/advisories/bulk', }) @@ -92,15 +129,61 @@ describe('security upgrade nudge', () => { expect(warn).not.toHaveBeenCalled() }) + it.each(['blocked', 'unknown'] as const)(+ 'preserves the advisory and retry behavior when target eligibility is %s',+ async (status) => {+ jest.mocked(getUpgradeAssessment).mockResolvedValue({+ reference: 'https://api.github.com/advisories?affects=next',+ affected: true,+ upgrade: { status, reason: 'Target assessment detail.' },+ })+ let message = ''+ try {+ await run()+ } catch (error) {+ expect(error).toMatchObject({ name: 'SecurityFatalError', exitCode: 1 })+ message = (error as Error).message+ }+ expect(message).toContain('affected by a published security advisory')+ expect(message).toContain('Target assessment detail.')+ expect(message.includes('next upgrade --ai')).toBe(false)+ expect(message.includes('can be automatically upgraded')).toBe(false)+ expect(message).toContain(+ status === 'unknown'+ ? 'Upgrade availability could not be checked.'+ : 'No safe newer target is available'+ )+ await expect(run()).resolves.toBeUndefined()+ expect(warn).toHaveBeenCalledWith(+ expect.stringContaining('Target assessment detail.')+ )+ }+ )+ it('stays silent when the version is unaffected', async () => { await run() - expect(getSecurityAdvisory).toHaveBeenCalledTimes(1)+ expect(getUpgradeAssessment).toHaveBeenCalledTimes(1) expect(warn).not.toHaveBeenCalled() }) - it('stays silent when prerelease security assessment is deferred', async () => {- jest.mocked(getSecurityAdvisory).mockResolvedValue(null)+ it('stays silent when canary security assessment is unsupported', async () => {+ jest.mocked(getUpgradeAssessment).mockResolvedValue({+ affected: null,+ reference: null,+ upgrade: {+ status: 'blocked',+ reason:+ 'Security upgrades are not supported for canary versions of Next.js.',+ },+ })+ await expect(run()).resolves.toBeUndefined()+ expect(warn).toHaveBeenCalledTimes(0)+ expect(getUpgradeAssessment).toHaveBeenCalledTimes(1)+ })++ it('stays silent when no security advisory matches', async () => {+ mockUpgrade() await run() @@ -112,13 +195,13 @@ describe('security upgrade nudge', () => { await run() - expect(getSecurityAdvisory).not.toHaveBeenCalled()+ expect(getUpgradeAssessment).not.toHaveBeenCalled() expect(warn).not.toHaveBeenCalled() }) it('warns without rejecting when advisory lookup fails', async () => { jest- .mocked(getSecurityAdvisory)+ .mocked(getUpgradeAssessment) .mockRejectedValue(new Error('Advisory service unavailable')) await expect(run()).resolves.toBeUndefined()@@ -133,7 +216,15 @@ describe('security upgrade nudge', () => { }) it('allows one matching retry with a warning', async () => {- jest.mocked(getSecurityAdvisory).mockResolvedValue({+ jest.mocked(getUpgradeAssessment).mockResolvedValue({+ affected: true,+ upgrade: {+ status: 'ready',+ installedVersion: '13.0.0',+ targetVersion: '17.2.1',+ references: [],+ futureDefaults: [],+ }, reference: 'https://api.github.com/advisories?affects=next%4013.0.0', }) @@ -150,7 +241,15 @@ describe('security upgrade nudge', () => { }) it('keeps dev and build retry receipts independent', async () => {- jest.mocked(getSecurityAdvisory).mockResolvedValue({+ jest.mocked(getUpgradeAssessment).mockResolvedValue({+ affected: true,+ upgrade: {+ status: 'ready',+ installedVersion: '13.0.0',+ targetVersion: '17.2.1',+ references: [],+ futureDefaults: [],+ }, reference: 'https://api.github.com/advisories?affects=next%4013.0.0', }) @@ -182,14 +281,19 @@ describe('latest nudge release selection', () => { ['16.1.0', '17.0.0-canary.1', null], ['16.1.0-canary.1', '16.1.0', null], ['16.0.0-canary.1', '16.1.0', null],+ ['17.2.0-canary.4', '17.2.0-canary.9', null],+ ['17.2.0-canary.9', '17.2.0-canary.10', null],+ ['17.2.0-canary.4', '17.2.1-canary.0', null],+ ['17.2.0-canary.4', '17.3.0-canary.0', '17.3.0-canary.0'],+ ['17.2.0-canary.4', '18.0.0-canary.0', '18.0.0-canary.0'],+ ['17.2.0-canary.4', '17.2.0-canary.4', null],+ ['17.2.0-canary.4', '17.1.0-canary.99', null],+ ['17.2.0-canary.4', '17.3.0-rc.1', null],+ ['17.2.0-rc.1', '17.3.0', null], ])( 'selects %s → %s for a nudge only across major/minor versions', async (installed, latest, expected) => {- jest- .spyOn(global, 'fetch')- .mockResolvedValue(new Response(JSON.stringify({ version: latest })))-- await expect(readLatestUpgradeVersion(installed)).resolves.toBe(expected)+ expect(readLatestUpgradeVersion(installed, latest)).toBe(expected) } ) })@@ -198,12 +302,35 @@ describe('latest upgrade nudge', () => { beforeEach(() => { jest.resetAllMocks() jest.mocked(getAgentName).mockResolvedValue('codex')- jest.mocked(getSecurityAdvisory).mockResolvedValue(null)- jest.mocked(getLatestUpgradeVersion).mockResolvedValue(null)+ mockUpgrade() }) + it.each(['latest', 'future'] as const)(+ 'links a canary %s reminder to the selected channel',+ async (policy) => {+ process.env.__NEXT_VERSION = '17.2.0-canary.4'+ mockUpgrade('17.3.0-canary.1')+ await expect(+ nudgeForUpgrade(directory, config(policy), 'build')+ ).rejects.toMatchObject({+ name: 'UpgradeNudgeError',+ message: expect.stringContaining(+ 'Reference: https://registry.npmjs.org/next/canary'+ ),+ })+ await expect(+ nudgeForUpgrade(directory, config(policy), 'build')+ ).resolves.toBeUndefined()+ expect(warn).toHaveBeenCalledWith(+ expect.stringContaining(+ 'Reference: https://registry.npmjs.org/next/canary'+ )+ )+ }+ )+ it('stops once and allows a matching retry with a warning', async () => {- jest.mocked(getLatestUpgradeVersion).mockResolvedValue('17.0.0')+ mockUpgrade('17.0.0') await expect( nudgeForUpgrade(directory, config('latest'), 'build')@@ -218,18 +345,38 @@ describe('latest upgrade nudge', () => { nudgeForUpgrade(directory, config('latest'), 'build') ).resolves.toBeUndefined() - expect(getLatestUpgradeVersion).toHaveBeenCalledTimes(2)+ expect(getUpgradeAssessment).toHaveBeenCalledTimes(2) expect(warn).toHaveBeenCalledWith( expect.stringMatching( /Next\.js 17\.0\.0 is available\.[\s\S]*continuing after the reminder you configured[\s\S]*registry\.npmjs\.org/ ) ) }) + it.each(['latest', 'future'] as const)(+ 'does not offer an unsafe %s target or fall through to Future adoption',+ async (policy) => {+ jest.mocked(getUpgradeAssessment).mockResolvedValue({+ affected: false,+ reference: 'https://api.github.com/advisories',+ upgrade: { status: 'blocked', reason: 'The target is affected.' },+ })+ await expect(+ nudgeForUpgrade(+ directory,+ config(policy, { cacheComponents: false }),+ 'build'+ )+ ).resolves.toBeUndefined()+ expect(warn).toHaveBeenCalledTimes(0)+ expect(getUpgradeAssessment).toHaveBeenCalledTimes(1)+ }+ )+ it('stays silent when there is no newer stable release', async () => { await nudgeForUpgrade(directory, config('latest'), 'build') - expect(getLatestUpgradeVersion).toHaveBeenCalledTimes(1)+ expect(getUpgradeAssessment).toHaveBeenCalledTimes(1) expect(warn).not.toHaveBeenCalled() }) @@ -238,14 +385,16 @@ describe('latest upgrade nudge', () => { await nudgeForUpgrade(directory, config('latest'), 'build') - expect(getLatestUpgradeVersion).not.toHaveBeenCalled()+ expect(getUpgradeAssessment).not.toHaveBeenCalled() expect(warn).not.toHaveBeenCalled() }) it('stays silent without rejecting when release lookup fails', async () => {- jest- .mocked(getLatestUpgradeVersion)- .mockRejectedValue(new Error('Registry unavailable'))+ jest.mocked(getUpgradeAssessment).mockResolvedValue({+ affected: false,+ reference: 'https://api.github.com/advisories',+ upgrade: { status: 'unknown', reason: 'Registry unavailable' },+ }) await expect( nudgeForUpgrade(directory, config('latest'), 'build')@@ -259,14 +408,21 @@ describe('composed latest nudge', () => { beforeEach(() => { jest.resetAllMocks() jest.mocked(getAgentName).mockResolvedValue('codex')- jest.mocked(getSecurityAdvisory).mockResolvedValue(null)- jest.mocked(getLatestUpgradeVersion).mockResolvedValue('16.0.0')+ mockUpgrade('17.0.0') }) it.each(['latest', 'future'] as const)( 'preserves the %s policy when security takes priority', async (policy) => {- jest.mocked(getSecurityAdvisory).mockResolvedValue({+ jest.mocked(getUpgradeAssessment).mockResolvedValue({+ affected: true,+ upgrade: {+ status: 'ready',+ installedVersion: '13.0.0',+ targetVersion: '17.2.1',+ references: [],+ futureDefaults: [],+ }, reference: 'https://api.github.com/advisories?affects=next%4015.0.0', }) @@ -282,8 +438,12 @@ describe('composed latest nudge', () => { ), }) + expect(getUpgradeAssessment).toHaveBeenCalledWith(+ expect.any(String),+ policy+ ) expect(warn).not.toHaveBeenCalled()- expect(getLatestUpgradeVersion).not.toHaveBeenCalled()+ expect(getUpgradeAssessment).toHaveBeenCalledTimes(1) } ) })@@ -292,19 +452,57 @@ describe('composed future nudge', () => { beforeEach(() => { jest.resetAllMocks() jest.mocked(getAgentName).mockResolvedValue('codex')- jest.mocked(getSecurityAdvisory).mockResolvedValue(null)- jest.mocked(getLatestUpgradeVersion).mockResolvedValue(null)+ mockUpgrade() }) - it('does not offer Future Defaults from a prerelease', async () => {+ it.each(['16.3.0-canary.1', '16.4.0-rc.1', '16.4.0-beta.1'])(+ 'does not offer defaults before stable availability or for another prerelease: %s',+ async (version) => {+ await expect(+ getFutureUpgrade(config('future', { cacheComponents: false }), version)+ ).resolves.toBeNull()+ }+ )++ it('offers available defaults on canary without a version reminder', async () => { await expect( getFutureUpgrade( config('future', { cacheComponents: false }), '16.4.0-canary.1' )+ ).resolves.toEqual({+ installedVersion: '16.4.0-canary.1',+ names: ['Cache Components'],+ })+ })++ it('does not remind about defaults already adopted on canary', async () => {+ await expect(+ getFutureUpgrade(+ config('future', { cacheComponents: true }),+ '16.4.0-canary.1'+ ) ).resolves.toBeNull() }) + it('offers canary Future adoption when advisory assessment is skipped', async () => {+ process.env.__NEXT_VERSION = '17.2.0-canary.4'+ mockUpgrade()+ await expect(+ nudgeForUpgrade(+ directory,+ config('future', { cacheComponents: false }),+ 'build'+ )+ ).rejects.toMatchObject({+ name: 'UpgradeNudgeError',+ message: expect.stringContaining(+ 'We recommend you adopt these Future Defaults.'+ ),+ })+ expect(getUpgradeAssessment).toHaveBeenCalledTimes(1)+ })+ it('names available Future Defaults using the adapter', async () => { await expect( getFutureUpgrade(config('future', { cacheComponents: false }), '16.4.0')@@ -321,7 +519,7 @@ describe('composed future nudge', () => { }) it('stops for a required latest upgrade before Future Defaults', async () => {- jest.mocked(getLatestUpgradeVersion).mockResolvedValue('17.0.0')+ mockUpgrade('17.0.0') await expect( nudgeForUpgrade(@@ -332,7 +530,7 @@ describe('composed future nudge', () => { ).rejects.toMatchObject({ name: 'UpgradeNudgeError', message: expect.stringMatching(- /next upgrade --ai\n```[\s\S]*agenticAutoUpgrade: 'future'/+ /Next\.js 17\.0\.0 is available[\s\S]*next upgrade --ai\n```[\s\S]*agenticAutoUpgrade: 'future'/ ), }) })