diff --git a/.github/workflows/build_and_test.yml b/.github/workflows/build_and_test.yml index 2d0d10b33d273..dad94c974da85 100644 --- a/.github/workflows/build_and_test.yml +++ b/.github/workflows/build_and_test.yml @@ -722,7 +722,7 @@ jobs: afterBuild: | export __NEXT_CACHE_COMPONENTS=true export __NEXT_EXPERIMENTAL_DEBUG_CHANNEL=true - export NEXT_EXTERNAL_TESTS_FILTERS="test/cache-components-tests-manifest.json" + export NEXT_EXTERNAL_TESTS_FILTERS="test/deploy-tests-manifest.json,test/cache-components-tests-manifest.json" export NEXT_E2E_TEST_TIMEOUT=240000 export GH_PR_NUMBER=${{ github.event.pull_request && github.event.pull_request.number || '' }} node scripts/test-new-tests.mjs \ diff --git a/contributing/core/testing.md b/contributing/core/testing.md index 89c4cb5de1b38..aac7f6db55274 100644 --- a/contributing/core/testing.md +++ b/contributing/core/testing.md @@ -92,6 +92,8 @@ these can be leveraged by prefixing the `pnpm test` command. it can be used when not using `pnpm test-dev` or `pnpm test-start` directly. Valid test modes can be seen here: https://github.com/vercel/next.js/blob/aa664868c102ddc5adc618415162d124503ad12e/test/lib/e2e-utils.ts#L46 +- Use `NEXT_TEST_DEPLOY_URL` with `pnpm test-deploy` to skip the Vercel deploy step and run + deploy-mode assertions against an existing deployment URL. - You can use `NEXT_TEST_PREFER_OFFLINE=1` while testing to configure the package manager to include the [`--prefer-offline`](https://pnpm.io/cli/install#--prefer-offline) argument during test setup. This is helpful when running tests in internet-restricted environments such as planes or public Wi-Fi. diff --git a/lerna.json b/lerna.json index 0589fb4ffaadd..3c997c5cb3a2a 100644 --- a/lerna.json +++ b/lerna.json @@ -15,5 +15,5 @@ "registry": "https://registry.npmjs.org/" } }, - "version": "16.1.1-canary.35" + "version": "16.1.1-canary.36" } \ No newline at end of file diff --git a/packages/create-next-app/package.json b/packages/create-next-app/package.json index da3f02d9ea447..5d33bce62d7d1 100644 --- a/packages/create-next-app/package.json +++ b/packages/create-next-app/package.json @@ -1,6 +1,6 @@ { "name": "create-next-app", - "version": "16.1.1-canary.35", + "version": "16.1.1-canary.36", "keywords": [ "react", "next", diff --git a/packages/eslint-config-next/package.json b/packages/eslint-config-next/package.json index 50bdb5c0115df..90ff28bb35dc7 100644 --- a/packages/eslint-config-next/package.json +++ b/packages/eslint-config-next/package.json @@ -1,6 +1,6 @@ { "name": "eslint-config-next", - "version": "16.1.1-canary.35", + "version": "16.1.1-canary.36", "description": "ESLint configuration used by Next.js.", "license": "MIT", "repository": { @@ -12,7 +12,7 @@ "dist" ], "dependencies": { - "@next/eslint-plugin-next": "16.1.1-canary.35", + "@next/eslint-plugin-next": "16.1.1-canary.36", "eslint-import-resolver-node": "^0.3.6", "eslint-import-resolver-typescript": "^3.5.2", "eslint-plugin-import": "^2.32.0", diff --git a/packages/eslint-plugin-internal/package.json b/packages/eslint-plugin-internal/package.json index 23a95f85c0bd9..09727ae2bbd00 100644 --- a/packages/eslint-plugin-internal/package.json +++ b/packages/eslint-plugin-internal/package.json @@ -1,7 +1,7 @@ { "name": "@next/eslint-plugin-internal", "private": true, - "version": "16.1.1-canary.35", + "version": "16.1.1-canary.36", "description": "ESLint plugin for working on Next.js.", "exports": { ".": "./src/eslint-plugin-internal.js" diff --git a/packages/eslint-plugin-next/package.json b/packages/eslint-plugin-next/package.json index 9f0afd41aa55f..353acb45d98fa 100644 --- a/packages/eslint-plugin-next/package.json +++ b/packages/eslint-plugin-next/package.json @@ -1,6 +1,6 @@ { "name": "@next/eslint-plugin-next", - "version": "16.1.1-canary.35", + "version": "16.1.1-canary.36", "description": "ESLint plugin for Next.js.", "main": "dist/index.js", "types": "dist/index.d.ts", diff --git a/packages/font/package.json b/packages/font/package.json index 265fa7914ac14..6b59d3927352e 100644 --- a/packages/font/package.json +++ b/packages/font/package.json @@ -1,7 +1,7 @@ { "name": "@next/font", "private": true, - "version": "16.1.1-canary.35", + "version": "16.1.1-canary.36", "repository": { "url": "vercel/next.js", "directory": "packages/font" diff --git a/packages/next-bundle-analyzer/package.json b/packages/next-bundle-analyzer/package.json index 1e47eedb2e6ed..2c451bf2e4636 100644 --- a/packages/next-bundle-analyzer/package.json +++ b/packages/next-bundle-analyzer/package.json @@ -1,6 +1,6 @@ { "name": "@next/bundle-analyzer", - "version": "16.1.1-canary.35", + "version": "16.1.1-canary.36", "main": "index.js", "types": "index.d.ts", "license": "MIT", diff --git a/packages/next-codemod/package.json b/packages/next-codemod/package.json index f517d971d6b92..48598696c1d66 100644 --- a/packages/next-codemod/package.json +++ b/packages/next-codemod/package.json @@ -1,6 +1,6 @@ { "name": "@next/codemod", - "version": "16.1.1-canary.35", + "version": "16.1.1-canary.36", "license": "MIT", "repository": { "type": "git", diff --git a/packages/next-env/package.json b/packages/next-env/package.json index a0715e5d42123..cf1da86c73ab4 100644 --- a/packages/next-env/package.json +++ b/packages/next-env/package.json @@ -1,6 +1,6 @@ { "name": "@next/env", - "version": "16.1.1-canary.35", + "version": "16.1.1-canary.36", "keywords": [ "react", "next", diff --git a/packages/next-mdx/package.json b/packages/next-mdx/package.json index 5ddc3be046f09..fb897f0ce2e95 100644 --- a/packages/next-mdx/package.json +++ b/packages/next-mdx/package.json @@ -1,6 +1,6 @@ { "name": "@next/mdx", - "version": "16.1.1-canary.35", + "version": "16.1.1-canary.36", "main": "index.js", "license": "MIT", "repository": { diff --git a/packages/next-plugin-storybook/package.json b/packages/next-plugin-storybook/package.json index 76dbc91c8c6fb..1956d02445975 100644 --- a/packages/next-plugin-storybook/package.json +++ b/packages/next-plugin-storybook/package.json @@ -1,6 +1,6 @@ { "name": "@next/plugin-storybook", - "version": "16.1.1-canary.35", + "version": "16.1.1-canary.36", "repository": { "url": "vercel/next.js", "directory": "packages/next-plugin-storybook" diff --git a/packages/next-polyfill-module/package.json b/packages/next-polyfill-module/package.json index a1b959abb3462..3649fc25bc2d7 100644 --- a/packages/next-polyfill-module/package.json +++ b/packages/next-polyfill-module/package.json @@ -1,6 +1,6 @@ { "name": "@next/polyfill-module", - "version": "16.1.1-canary.35", + "version": "16.1.1-canary.36", "description": "A standard library polyfill for ES Modules supporting browsers (Edge 16+, Firefox 60+, Chrome 61+, Safari 10.1+)", "main": "dist/polyfill-module.js", "license": "MIT", diff --git a/packages/next-polyfill-nomodule/package.json b/packages/next-polyfill-nomodule/package.json index e2aa53280b0c3..1fa2bbbb56946 100644 --- a/packages/next-polyfill-nomodule/package.json +++ b/packages/next-polyfill-nomodule/package.json @@ -1,6 +1,6 @@ { "name": "@next/polyfill-nomodule", - "version": "16.1.1-canary.35", + "version": "16.1.1-canary.36", "description": "A polyfill for non-dead, nomodule browsers.", "main": "dist/polyfill-nomodule.js", "license": "MIT", diff --git a/packages/next-routing/package.json b/packages/next-routing/package.json index bb6f460f796f0..58c368d05076b 100644 --- a/packages/next-routing/package.json +++ b/packages/next-routing/package.json @@ -1,6 +1,6 @@ { "name": "@next/routing", - "version": "16.1.1-canary.35", + "version": "16.1.1-canary.36", "keywords": [ "react", "next", diff --git a/packages/next-rspack/package.json b/packages/next-rspack/package.json index 663929cd40b71..c00e167355089 100644 --- a/packages/next-rspack/package.json +++ b/packages/next-rspack/package.json @@ -1,6 +1,6 @@ { "name": "next-rspack", - "version": "16.1.1-canary.35", + "version": "16.1.1-canary.36", "repository": { "url": "vercel/next.js", "directory": "packages/next-rspack" diff --git a/packages/next-swc/package.json b/packages/next-swc/package.json index 1edc62d8aa702..8de6aa7437188 100644 --- a/packages/next-swc/package.json +++ b/packages/next-swc/package.json @@ -1,6 +1,6 @@ { "name": "@next/swc", - "version": "16.1.1-canary.35", + "version": "16.1.1-canary.36", "private": true, "files": [ "native/" diff --git a/packages/next/package.json b/packages/next/package.json index afbe3c9c17894..23d10225d5b87 100644 --- a/packages/next/package.json +++ b/packages/next/package.json @@ -1,6 +1,6 @@ { "name": "next", - "version": "16.1.1-canary.35", + "version": "16.1.1-canary.36", "description": "The React Framework", "main": "./dist/server/next.js", "license": "MIT", @@ -97,7 +97,7 @@ ] }, "dependencies": { - "@next/env": "16.1.1-canary.35", + "@next/env": "16.1.1-canary.36", "@swc/helpers": "0.5.15", "baseline-browser-mapping": "^2.8.3", "caniuse-lite": "^1.0.30001579", @@ -162,11 +162,11 @@ "@modelcontextprotocol/sdk": "1.18.1", "@mswjs/interceptors": "0.23.0", "@napi-rs/triples": "1.2.0", - "@next/font": "16.1.1-canary.35", - "@next/polyfill-module": "16.1.1-canary.35", - "@next/polyfill-nomodule": "16.1.1-canary.35", - "@next/react-refresh-utils": "16.1.1-canary.35", - "@next/swc": "16.1.1-canary.35", + "@next/font": "16.1.1-canary.36", + "@next/polyfill-module": "16.1.1-canary.36", + "@next/polyfill-nomodule": "16.1.1-canary.36", + "@next/react-refresh-utils": "16.1.1-canary.36", + "@next/swc": "16.1.1-canary.36", "@opentelemetry/api": "1.6.0", "@playwright/test": "1.51.1", "@rspack/core": "1.6.7", diff --git a/packages/react-refresh-utils/package.json b/packages/react-refresh-utils/package.json index 7b0e3f0b37a7b..443e43028125f 100644 --- a/packages/react-refresh-utils/package.json +++ b/packages/react-refresh-utils/package.json @@ -1,6 +1,6 @@ { "name": "@next/react-refresh-utils", - "version": "16.1.1-canary.35", + "version": "16.1.1-canary.36", "description": "An experimental package providing utilities for React Refresh.", "repository": { "url": "vercel/next.js", diff --git a/packages/third-parties/package.json b/packages/third-parties/package.json index 9fffb84225d53..8b5917d51018c 100644 --- a/packages/third-parties/package.json +++ b/packages/third-parties/package.json @@ -1,6 +1,6 @@ { "name": "@next/third-parties", - "version": "16.1.1-canary.35", + "version": "16.1.1-canary.36", "repository": { "url": "vercel/next.js", "directory": "packages/third-parties" @@ -26,7 +26,7 @@ "third-party-capital": "1.0.20" }, "devDependencies": { - "next": "16.1.1-canary.35", + "next": "16.1.1-canary.36", "outdent": "0.8.0", "prettier": "2.5.1", "typescript": "5.9.2" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 41b8a96f2fd28..e057d1e2e9069 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1008,7 +1008,7 @@ importers: packages/eslint-config-next: dependencies: '@next/eslint-plugin-next': - specifier: 16.1.1-canary.35 + specifier: 16.1.1-canary.36 version: link:../eslint-plugin-next eslint: specifier: '>=9.0.0' @@ -1085,7 +1085,7 @@ importers: packages/next: dependencies: '@next/env': - specifier: 16.1.1-canary.35 + specifier: 16.1.1-canary.36 version: link:../next-env '@swc/helpers': specifier: 0.5.15 @@ -1213,19 +1213,19 @@ importers: specifier: 1.2.0 version: 1.2.0 '@next/font': - specifier: 16.1.1-canary.35 + specifier: 16.1.1-canary.36 version: link:../font '@next/polyfill-module': - specifier: 16.1.1-canary.35 + specifier: 16.1.1-canary.36 version: link:../next-polyfill-module '@next/polyfill-nomodule': - specifier: 16.1.1-canary.35 + specifier: 16.1.1-canary.36 version: link:../next-polyfill-nomodule '@next/react-refresh-utils': - specifier: 16.1.1-canary.35 + specifier: 16.1.1-canary.36 version: link:../react-refresh-utils '@next/swc': - specifier: 16.1.1-canary.35 + specifier: 16.1.1-canary.36 version: link:../next-swc '@opentelemetry/api': specifier: 1.6.0 @@ -1943,7 +1943,7 @@ importers: version: 1.0.20 devDependencies: next: - specifier: 16.1.1-canary.35 + specifier: 16.1.1-canary.36 version: link:../next outdent: specifier: 0.8.0 diff --git a/test/get-test-filter.js b/test/get-test-filter.js index bf6cc218524b1..f6e83ba76be19 100644 --- a/test/get-test-filter.js +++ b/test/get-test-filter.js @@ -1,10 +1,50 @@ const path = require('path') const minimatch = require('minimatch') +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) +} + function getTestFilter() { - const manifest = process.env.NEXT_EXTERNAL_TESTS_FILTERS - ? require(path.resolve(process.env.NEXT_EXTERNAL_TESTS_FILTERS)) - : null + const manifest = getManifest() if (!manifest) return null console.log( diff --git a/test/lib/next-modes/next-deploy.ts b/test/lib/next-modes/next-deploy.ts index 0bedc822af01b..967376721092d 100644 --- a/test/lib/next-modes/next-deploy.ts +++ b/test/lib/next-modes/next-deploy.ts @@ -42,6 +42,8 @@ export class NextDeployInstance extends NextInstance { super.setup(parentSpan) await super.createTestDir({ parentSpan, skipInstall: true }) + const existingDeployUrl = process.env.NEXT_TEST_DEPLOY_URL?.trim() + // ensure Vercel CLI is installed try { const res = await execa('vercel', ['--version']) @@ -84,89 +86,101 @@ export class NextDeployInstance extends NextInstance { ) vercelFlags.push('--global-config', vcConfigDir) } - require('console').log(`Linking project at ${this.testDir}`) - - // link the project - const linkRes = await execa( - 'vercel', - ['link', '-p', TEST_PROJECT_NAME, '--yes', ...vercelFlags], - { - cwd: this.testDir, - env: vercelEnv, - reject: false, + if (existingDeployUrl) { + try { + this._url = new URL(existingDeployUrl).toString() + } catch (err) { + throw new Error( + `Invalid NEXT_TEST_DEPLOY_URL value: ${existingDeployUrl}`, + { cause: err } + ) } - ) - - if (linkRes.exitCode !== 0) { - throw new Error( - `Failed to link project ${linkRes.stdout} ${linkRes.stderr} (${linkRes.exitCode})` + require('console').log(`Using existing deployment URL: ${this._url}`) + } else { + require('console').log(`Linking project at ${this.testDir}`) + + // link the project + const linkRes = await execa( + 'vercel', + ['link', '-p', TEST_PROJECT_NAME, '--yes', ...vercelFlags], + { + cwd: this.testDir, + env: vercelEnv, + reject: false, + } ) - } - require('console').log(`Deploying project at ${this.testDir}`) - - const additionalEnv: string[] = [] - for (const key of Object.keys(this.env || {})) { - additionalEnv.push(`${key}=${this.env[key]}`) - } + if (linkRes.exitCode !== 0) { + throw new Error( + `Failed to link project ${linkRes.stdout} ${linkRes.stderr} (${linkRes.exitCode})` + ) + } + require('console').log(`Deploying project at ${this.testDir}`) - additionalEnv.push( - `VERCEL_CLI_VERSION=${process.env.VERCEL_CLI_VERSION || 'vercel@latest'}` - ) + const additionalEnv: string[] = [] - // Add experimental feature flags + for (const key of Object.keys(this.env || {})) { + additionalEnv.push(`${key}=${this.env[key]}`) + } - if (process.env.__NEXT_CACHE_COMPONENTS) { additionalEnv.push( - `NEXT_PRIVATE_EXPERIMENTAL_CACHE_COMPONENTS=${process.env.__NEXT_CACHE_COMPONENTS}` + `VERCEL_CLI_VERSION=${process.env.VERCEL_CLI_VERSION || 'vercel@latest'}` ) - } - if (process.env.IS_TURBOPACK_TEST) { - additionalEnv.push(`IS_TURBOPACK_TEST=1`) - } - if (process.env.IS_WEBPACK_TEST) { - additionalEnv.push(`IS_WEBPACK_TEST=1`) - } + // Add experimental feature flags - const deployRes = await execa( - 'vercel', - [ - 'deploy', - '--build-env', - 'NEXT_PRIVATE_TEST_MODE=e2e', - '--build-env', - 'NEXT_TELEMETRY_DISABLED=1', - '--build-env', - 'VERCEL_NEXT_BUNDLED_SERVER=1', - ...additionalEnv.flatMap((pair) => [ - '--env', - pair, - '--build-env', - pair, - ]), - '--force', - ...vercelFlags, - ], - { - cwd: this.testDir, - env: vercelEnv, - reject: false, - // This will print deployment information earlier to the console so we - // don't have to wait until the deployment is complete to get the - // inspect URL. - stderr: 'inherit', + if (process.env.__NEXT_CACHE_COMPONENTS) { + additionalEnv.push( + `NEXT_PRIVATE_EXPERIMENTAL_CACHE_COMPONENTS=${process.env.__NEXT_CACHE_COMPONENTS}` + ) + } + + if (process.env.IS_TURBOPACK_TEST) { + additionalEnv.push(`IS_TURBOPACK_TEST=1`) + } + if (process.env.IS_WEBPACK_TEST) { + additionalEnv.push(`IS_WEBPACK_TEST=1`) } - ) - if (deployRes.exitCode !== 0) { - throw new Error( - `Failed to deploy project ${deployRes.stdout} ${deployRes.stderr} (${deployRes.exitCode})` + const deployRes = await execa( + 'vercel', + [ + 'deploy', + '--build-env', + 'NEXT_PRIVATE_TEST_MODE=e2e', + '--build-env', + 'NEXT_TELEMETRY_DISABLED=1', + '--build-env', + 'VERCEL_NEXT_BUNDLED_SERVER=1', + ...additionalEnv.flatMap((pair) => [ + '--env', + pair, + '--build-env', + pair, + ]), + '--force', + ...vercelFlags, + ], + { + cwd: this.testDir, + env: vercelEnv, + reject: false, + // This will print deployment information earlier to the console so we + // don't have to wait until the deployment is complete to get the + // inspect URL. + stderr: 'inherit', + } ) - } - // the CLI gives just the deployment URL back when not a TTY - this._url = deployRes.stdout + if (deployRes.exitCode !== 0) { + throw new Error( + `Failed to deploy project ${deployRes.stdout} ${deployRes.stderr} (${deployRes.exitCode})` + ) + } + + // the CLI gives just the deployment URL back when not a TTY + this._url = deployRes.stdout + } this._parsedUrl = new URL(this._url) // If configured, we should configure the `/etc/hosts` file to point the diff --git a/turbopack/crates/turbo-tasks-fuzz/src/main.rs b/turbopack/crates/turbo-tasks-fuzz/src/main.rs index 83ad927858739..31a28ddf16d55 100644 --- a/turbopack/crates/turbo-tasks-fuzz/src/main.rs +++ b/turbopack/crates/turbo-tasks-fuzz/src/main.rs @@ -1,3 +1,5 @@ +#![cfg_attr(windows, feature(junction_point))] + use std::{ fs::OpenOptions, io::Write, @@ -7,7 +9,7 @@ use std::{ time::Duration, }; -use clap::{Args, Parser, Subcommand}; +use clap::{Args, Parser, Subcommand, ValueEnum}; use rand::{Rng, RngCore, SeedableRng}; use rustc_hash::FxHashSet; use tokio::time::sleep; @@ -55,6 +57,28 @@ struct FsWatcher { /// Call `start_watching` after the initial read of files instead of before (the default). #[arg(long)] start_watching_late: bool, + /// Enable symlink testing. The mode controls what kind of targets the symlinks point to. + #[arg(long, value_enum)] + symlinks: Option, + /// Total number of symlinks to create. + #[arg(long, default_value_t = 80, requires = "symlinks")] + symlink_count: u32, + /// Number of symlink modifications per iteration (only used when --symlinks is set). + #[arg(long, default_value_t = 20, requires = "symlinks")] + symlink_modifications: u32, +} + +#[derive(Clone, Copy, Debug, ValueEnum)] +enum SymlinkMode { + /// Test file symlinks + #[cfg_attr(windows, doc = "(requires developer mode or admin)")] + File, + /// Test directory symlinks + #[cfg_attr(windows, doc = "(requires developer mode or admin)")] + Directory, + /// Test junction points (Windows-only) + #[cfg(windows)] + Junction, } #[tokio::main] @@ -80,6 +104,7 @@ async fn fuzz_fs_watcher(args: FsWatcher) -> anyhow::Result<()> { BackendOptions::default(), noop_backing_storage(), )); + tt.run_once(async move { let invalidations = TransientInstance::new(PathInvalidations::default()); let fs_root_rcstr = RcStr::from(fs_root.to_str().unwrap()); @@ -91,14 +116,30 @@ async fn fuzz_fs_watcher(args: FsWatcher) -> anyhow::Result<()> { .await? .owned() .await?; + create_directory_tree(&mut FxHashSet::default(), &fs_root, args.depth, args.width)?; + let mut symlink_targets = if let Some(mode) = args.symlinks { + create_initial_symlinks(&fs_root, mode, args.symlink_count, args.depth)? + } else { + Vec::new() + }; + if !args.start_watching_late { project_fs.await?.start_watching(None).await?; } - let read_all_paths_op = - read_all_paths_operation(invalidations.clone(), project_root, args.depth, args.width); + let read_all_paths_op = read_all_paths_operation( + invalidations.clone(), + project_root.clone(), + args.depth, + args.width, + if args.symlinks.is_some() { + args.symlink_count + } else { + 0 + }, + ); read_all_paths_op.read_strongly_consistent().await?; { let mut invalidations = invalidations.0.lock().unwrap(); @@ -134,15 +175,44 @@ async fn fuzz_fs_watcher(args: FsWatcher) -> anyhow::Result<()> { args.width, )?; } + + if let Some(mode) = args.symlinks + && !symlink_targets.is_empty() + { + for _ in 0..args.symlink_modifications { + let symlink_idx = rng.random_range(0..symlink_targets.len()); + let old_target = &symlink_targets[symlink_idx]; + + let new_target_relative = pick_random_link_target(args.depth, args.width, mode); + + if new_target_relative != *old_target { + let symlink_path = fs_root.join("_symlinks").join(symlink_idx.to_string()); + let relative_target = Path::new("..").join(&new_target_relative); + + remove_symlink(&symlink_path, mode)?; + create_symlink(&symlink_path, &relative_target, mode)?; + + modified_file_paths.insert(symlink_path); + symlink_targets[symlink_idx] = new_target_relative; + } + } + } + // there's no way to know when we've received all the pending events from the operating // system, so just sleep and pray sleep(Duration::from_millis(args.notify_timeout_ms)).await; read_all_paths_op.read_strongly_consistent().await?; { let mut invalidations = invalidations.0.lock().unwrap(); + let symlink_info = if args.symlinks.is_some() { + " and symlinks" + } else { + "" + }; println!( - "modified {} files and found {} invalidations", + "modified {} files{}. found {} invalidations", modified_file_paths.len(), + symlink_info, invalidations.len() ); if args.print_missing_invalidations { @@ -186,12 +256,24 @@ async fn read_path( Ok(()) } +#[turbo_tasks::function] +async fn read_link( + invalidations: TransientInstance, + path: FileSystemPath, +) -> anyhow::Result<()> { + let path_str = path.path.clone(); + invalidations.0.lock().unwrap().insert(path_str); + let _ = path.read_link().await?; + Ok(()) +} + #[turbo_tasks::function(operation)] async fn read_all_paths_operation( invalidations: TransientInstance, root: FileSystemPath, depth: usize, width: usize, + symlink_count: u32, ) -> anyhow::Result<()> { async fn read_all_paths_inner( invalidations: TransientInstance, @@ -216,7 +298,15 @@ async fn read_all_paths_operation( } Ok(()) } - read_all_paths_inner(invalidations, root, depth, width).await + read_all_paths_inner(invalidations.clone(), root.clone(), depth, width).await?; + + let symlinks_dir = root.join("_symlinks")?; + for i in 0..symlink_count { + let symlink_path = symlinks_dir.join(&i.to_string())?; + read_link(invalidations.clone(), symlink_path).await?; + } + + Ok(()) } fn create_directory_tree( @@ -244,6 +334,87 @@ fn create_directory_tree( Ok(()) } +fn create_initial_symlinks( + fs_root: &Path, + symlink_mode: SymlinkMode, + symlink_count: u32, + depth: usize, +) -> anyhow::Result> { + // Use a dedicated "symlinks" directory to avoid conflicts + let symlinks_dir = fs_root.join("_symlinks"); + std::fs::create_dir_all(&symlinks_dir)?; + + let initial_target_relative = match symlink_mode { + SymlinkMode::File => { + // Point to a file at depth: 0/0/0/.../0 + let mut path = PathBuf::new(); + for _ in 0..depth { + path.push("0"); + } + path + } + SymlinkMode::Directory => PathBuf::from("0"), + #[cfg(windows)] + SymlinkMode::Junction => PathBuf::from("0"), + }; + + let relative_target = Path::new("..").join(&initial_target_relative); + + let mut symlink_targets = Vec::new(); + for i in 0..symlink_count { + let symlink_path = symlinks_dir.join(i.to_string()); + create_symlink(&symlink_path, &relative_target, symlink_mode)?; + symlink_targets.push(initial_target_relative.clone()); + } + + Ok(symlink_targets) +} + +fn create_symlink(link_path: &Path, target: &Path, mode: SymlinkMode) -> anyhow::Result<()> { + #[cfg(unix)] + { + let _ = mode; + std::os::unix::fs::symlink(target, link_path)?; + } + #[cfg(windows)] + { + match mode { + SymlinkMode::File => { + std::os::windows::fs::symlink_file(target, link_path)?; + } + SymlinkMode::Directory => { + std::os::windows::fs::symlink_dir(target, link_path)?; + } + SymlinkMode::Junction => { + // Junction points require absolute paths + let absolute_target = link_path.parent().unwrap_or(link_path).join(target); + std::os::windows::fs::junction_point(&absolute_target, link_path)?; + } + } + } + Ok(()) +} + +fn remove_symlink(link_path: &Path, mode: SymlinkMode) -> anyhow::Result<()> { + #[cfg(unix)] + { + let _ = mode; + std::fs::remove_file(link_path)?; + } + #[cfg(windows)] + { + match mode { + SymlinkMode::File | SymlinkMode::Directory => { + std::fs::remove_file(link_path)?; + } + SymlinkMode::Junction => { + std::fs::remove_dir(link_path)?; + } + } + } + Ok(()) +} + fn pick_random_file(depth: usize, width: usize) -> PathBuf { let mut rng = rand::rng(); iter::repeat_with(|| rng.random_range(0..width).to_string()) @@ -266,6 +437,15 @@ fn pick_random_directory(max_depth: usize, width: usize) -> RandomDirectory { RandomDirectory { depth, path } } +fn pick_random_link_target(depth: usize, width: usize, mode: SymlinkMode) -> PathBuf { + match mode { + SymlinkMode::File => pick_random_file(depth, width), + SymlinkMode::Directory => pick_random_directory(depth, width).path, + #[cfg(windows)] + SymlinkMode::Junction => pick_random_directory(depth, width).path, + } +} + struct FsCleanup<'a> { path: &'a Path, }