diff --git a/.nx/workflows/dynamic-changesets.yaml b/.nx/workflows/dynamic-changesets.yaml index 786111053d..39d858d5b2 100644 --- a/.nx/workflows/dynamic-changesets.yaml +++ b/.nx/workflows/dynamic-changesets.yaml @@ -1,4 +1,4 @@ distribute-on: small-changeset: 4 linux-medium-js medium-changeset: 8 linux-medium-js - large-changeset: 14 linux-medium-js + large-changeset: 16 linux-medium-js diff --git a/.nx/workflows/sandboxing-config.yaml b/.nx/workflows/sandboxing-config.yaml new file mode 100644 index 0000000000..4fa2634bc3 --- /dev/null +++ b/.nx/workflows/sandboxing-config.yaml @@ -0,0 +1,9 @@ +exclude-reads: + - e2e/.gitignore + - '**/src/routeTree.gen.ts' +exclude-writes: + - '**/src/routeTree.gen.ts' +task-exclusions: + - target: test:build + exclude-reads: + - LICENSE diff --git a/e2e/react-start/import-protection/package.json b/e2e/react-start/import-protection/package.json index 2d3010470e..5459b84437 100644 --- a/e2e/react-start/import-protection/package.json +++ b/e2e/react-start/import-protection/package.json @@ -11,10 +11,7 @@ "build": "pnpm build:vite", "build:vite": "vite build && tsc --noEmit", "build:rsbuild": "rsbuild build && tsc --noEmit", - "start": "node server.js", - "test:e2e:mockMode": "rm -rf node_modules/.vite; rm -rf port*.txt; playwright test --project=chromium", - "test:e2e:errorMode": "rm -rf node_modules/.vite; rm -rf port*.txt; BEHAVIOR=error playwright test --project=chromium", - "test:e2e": "pnpm run test:e2e:mockMode && pnpm run test:e2e:errorMode" + "start": "node server.js" }, "dependencies": { "@tanstack/react-router": "workspace:^", @@ -58,6 +55,7 @@ "toolchain": "vite", "mode": "ssr", "env": { + "E2E_BUILD_LOG": "webserver-build.vite-ssr.log", "E2E_VITE_BUNDLED_DEV": "false" } }, @@ -66,12 +64,16 @@ "mode": "ssr", "name": "bundled-dev", "env": { + "E2E_BUILD_LOG": "webserver-build.vite-ssr-bundled-dev.log", "E2E_VITE_BUNDLED_DEV": "true" } }, { "toolchain": "rsbuild", - "mode": "ssr" + "mode": "ssr", + "env": { + "E2E_BUILD_LOG": "webserver-build.rsbuild-ssr.log" + } } ] } diff --git a/e2e/react-start/import-protection/playwright.config.ts b/e2e/react-start/import-protection/playwright.config.ts index ac239f36fe..778e865125 100644 --- a/e2e/react-start/import-protection/playwright.config.ts +++ b/e2e/react-start/import-protection/playwright.config.ts @@ -1,6 +1,7 @@ import { defineConfig, devices } from '@playwright/test' import { getTestServerPort } from '@tanstack/router-e2e-utils' import { isErrorMode } from './tests/utils/isErrorMode' +import { getViolationArtifactName } from './tests/violations.utils' import packageJson from './package.json' with { type: 'json' } const toolchain = process.env.E2E_TOOLCHAIN ?? 'vite' @@ -11,8 +12,12 @@ const e2ePortKey = const distDir = process.env.E2E_DIST_DIR ?? 'dist' const PORT = await getTestServerPort(e2ePortKey) const baseURL = `http://localhost:${PORT}` -const buildCommand = - toolchain === 'rsbuild' ? 'pnpm build:rsbuild' : 'pnpm build:vite' +const violationArtifacts = [ + getViolationArtifactName('build'), + getViolationArtifactName('dev'), + getViolationArtifactName('dev.cold'), + getViolationArtifactName('dev.warm'), +].join(' ') console.log('running in error mode:', isErrorMode.toString()) @@ -36,7 +41,7 @@ export default defineConfig({ ? {} : { webServer: { - command: `rm -f webserver-build.log webserver-dev.log violations.build.json violations.dev.json && VITE_SERVER_PORT=${PORT} PORT=${PORT} ${buildCommand} > webserver-build.log 2>&1 && PORT=${PORT} VITE_SERVER_PORT=${PORT} pnpm start`, + command: `rm -f webserver-dev.log ${violationArtifacts} && PORT=${PORT} VITE_SERVER_PORT=${PORT} pnpm start`, url: baseURL, reuseExistingServer: !process.env.CI, stdout: 'pipe', diff --git a/e2e/react-start/import-protection/tests/import-protection.spec.ts b/e2e/react-start/import-protection/tests/import-protection.spec.ts index cdc9aea32c..16b5b073eb 100644 --- a/e2e/react-start/import-protection/tests/import-protection.spec.ts +++ b/e2e/react-start/import-protection/tests/import-protection.spec.ts @@ -2,6 +2,7 @@ import path from 'node:path' import fs from 'node:fs' import { expect } from '@playwright/test' import { test } from '@tanstack/router-e2e-utils' +import { getViolationArtifactName } from './violations.utils' import type { Violation } from './violations.utils' import type { Page } from '@playwright/test' @@ -37,7 +38,7 @@ function normalizeKeyPath(value: string): string { async function readViolations( type: 'build' | 'dev' | 'dev.cold' | 'dev.warm', ): Promise> { - const filename = `violations.${type}.json` + const filename = getViolationArtifactName(type) const violationsPath = path.resolve(import.meta.dirname, '..', filename) const mod = await import(violationsPath, { with: { type: 'json' }, @@ -161,7 +162,8 @@ test('client-only violations route loads in mock mode', async ({ page }) => { }) for (const mode of ['build', 'dev'] as const) { - test(`violations.${mode}.json is written during ${mode}`, async () => { + const artifactName = getViolationArtifactName(mode) + test(`${artifactName} is written during ${mode}`, async () => { const violations = await readViolations(mode) expect(violations.length).toBeGreaterThan(0) }) @@ -204,7 +206,7 @@ test('build log does not contain mock-edge missing export warnings', () => { const buildLogPath = path.resolve( import.meta.dirname, '..', - 'webserver-build.log', + process.env.E2E_BUILD_LOG ?? 'webserver-build.log', ) if (!fs.existsSync(buildLogPath)) { diff --git a/e2e/react-start/import-protection/tests/violations.setup.ts b/e2e/react-start/import-protection/tests/violations.setup.ts index 02796227a7..2f863b398e 100644 --- a/e2e/react-start/import-protection/tests/violations.setup.ts +++ b/e2e/react-start/import-protection/tests/violations.setup.ts @@ -5,7 +5,10 @@ import { chromium } from '@playwright/test' import { getTestServerPort } from '@tanstack/router-e2e-utils' import packageJson from '../package.json' with { type: 'json' } -import { extractViolationsFromLog } from './violations.utils' +import { + extractViolationsFromLog, + getViolationArtifactName, +} from './violations.utils' import type { FullConfig } from '@playwright/test' import type { Violation } from './violations.utils' @@ -250,11 +253,11 @@ async function captureDevViolations(cwd: string): Promise { ) fs.writeFileSync( - path.resolve(cwd, 'violations.dev.json'), + path.resolve(cwd, getViolationArtifactName('dev')), JSON.stringify(coldViolations, null, 2), ) fs.writeFileSync( - path.resolve(cwd, 'violations.dev.cold.json'), + path.resolve(cwd, getViolationArtifactName('dev.cold')), JSON.stringify(coldViolations, null, 2), ) @@ -265,7 +268,7 @@ async function captureDevViolations(cwd: string): Promise { ) fs.writeFileSync( - path.resolve(cwd, 'violations.dev.warm.json'), + path.resolve(cwd, getViolationArtifactName('dev.warm')), JSON.stringify(warmViolations, null, 2), ) } @@ -275,19 +278,22 @@ export default async function globalSetup(config: FullConfig) { // This file lives in ./tests; fixture root is one directory up. const cwd = path.resolve(import.meta.dirname, '..') - // webServer.command writes build output to this file. - const logFile = path.resolve(cwd, 'webserver-build.log') + // The Nx build dependency writes build output to this file. + const logFile = path.resolve( + cwd, + process.env.E2E_BUILD_LOG ?? 'webserver-build.log', + ) if (!fs.existsSync(logFile)) { // If the log doesn't exist, leave an empty violations file. - fs.writeFileSync(path.resolve(cwd, 'violations.build.json'), '[]') + fs.writeFileSync(path.resolve(cwd, getViolationArtifactName('build')), '[]') return } const text = fs.readFileSync(logFile, 'utf-8') const violations = extractViolationsFromLog(text) fs.writeFileSync( - path.resolve(cwd, 'violations.build.json'), + path.resolve(cwd, getViolationArtifactName('build')), JSON.stringify(violations, null, 2), ) diff --git a/e2e/react-start/import-protection/tests/violations.utils.ts b/e2e/react-start/import-protection/tests/violations.utils.ts index c08a9b7a82..006c51543b 100644 --- a/e2e/react-start/import-protection/tests/violations.utils.ts +++ b/e2e/react-start/import-protection/tests/violations.utils.ts @@ -21,6 +21,15 @@ export type Violation = { message?: string } +export type ViolationArtifact = 'build' | 'dev' | 'dev.cold' | 'dev.warm' + +export function getViolationArtifactName(artifact: ViolationArtifact): string { + const modeKey = process.env.E2E_MODE_KEY + return modeKey + ? `violations.${modeKey}.${artifact}.json` + : `violations.${artifact}.json` +} + export function stripAnsi(input: string): string { // eslint-disable-next-line no-control-regex return input.replace(/\u001b\[[0-9;]*m/g, '') diff --git a/nx.json b/nx.json index 08e7563642..764112a2f6 100644 --- a/nx.json +++ b/nx.json @@ -9,10 +9,18 @@ }, "namedInputs": { "sharedGlobals": [ + "{workspaceRoot}/.npmrc", + "{workspaceRoot}/.nx/workflows/sandboxing-config.yaml", "{workspaceRoot}/.nvmrc", "{workspaceRoot}/package.json", "{workspaceRoot}/tsconfig.json" ], + "dependentTaskOutputs": [ + { + "dependentTasksOutputFiles": "**/*", + "transitive": true + } + ], "default": [ "sharedGlobals", "{projectRoot}/**/*", @@ -22,7 +30,8 @@ "default", "!{projectRoot}/tests/**/*", "!{projectRoot}/eslint.config.js" - ] + ], + "buildProduction": ["sharedGlobals", "{projectRoot}/**/*"] }, "targetDefaults": { "test:docs": { @@ -31,19 +40,34 @@ }, "test:eslint": { "cache": true, - "dependsOn": ["^build"], - "inputs": ["default", "^production", "{workspaceRoot}/eslint.config.js"] + "dependsOn": ["^build", "build"], + "inputs": [ + "default", + "^production", + "{workspaceRoot}/eslint.config.js", + "dependentTaskOutputs" + ] }, "test:unit": { "cache": true, "dependsOn": ["^build"], - "inputs": ["default", "^production"], + "inputs": [ + "default", + "^production", + "{workspaceRoot}/eslint.config.js", + "dependentTaskOutputs" + ], "outputs": ["{projectRoot}/coverage"] }, "test:e2e": { "cache": true, "dependsOn": ["^build", "test:e2e--*"], - "inputs": ["default", "^production"] + "inputs": ["default", "^production", "dependentTaskOutputs"], + "outputs": [ + "{projectRoot}/dist", + "{projectRoot}/port-*.txt", + "{projectRoot}/test-results" + ] }, "test:e2e:nitro": { "cache": true, @@ -62,24 +86,40 @@ }, "test:types": { "cache": true, - "dependsOn": ["^build"], - "inputs": ["default", "^production"] + "dependsOn": ["^build", "build"], + "inputs": [ + "default", + "^production", + "{workspaceRoot}/eslint.config.js", + "dependentTaskOutputs" + ] }, "test:types:ssr": { "cache": true, - "dependsOn": ["^build"], - "inputs": ["default", "^production"] + "dependsOn": ["^build", "build"], + "inputs": [ + "default", + "^production", + "{workspaceRoot}/benchmarks/ssr/bench-utils.ts", + "dependentTaskOutputs" + ] }, "build": { "cache": true, "dependsOn": ["^build"], - "inputs": ["production", "^production"], - "outputs": ["{projectRoot}/build", "{projectRoot}/dist"] + "inputs": ["buildProduction", "^buildProduction", "dependentTaskOutputs"], + "outputs": [ + "{projectRoot}/.netlify", + "{projectRoot}/.wrangler", + "{projectRoot}/.output", + "{projectRoot}/build", + "{projectRoot}/dist" + ] }, "test:build": { "cache": true, "dependsOn": ["build"], - "inputs": ["production"] + "inputs": ["buildProduction", "dependentTaskOutputs"] } }, "plugins": [ diff --git a/scripts/nx/playwright-plugin.ts b/scripts/nx/playwright-plugin.ts index d02276d269..675b976613 100644 --- a/scripts/nx/playwright-plugin.ts +++ b/scripts/nx/playwright-plugin.ts @@ -90,10 +90,15 @@ function createNodesInternal( const CI_TARGET_NAME = 'test:e2e' const MODE_TARGET_SEPARATOR = '--' -const TEST_INPUTS: TargetConfiguration['inputs'] = ['default', '^production'] +const TEST_INPUTS: TargetConfiguration['inputs'] = [ + 'default', + 'dependentTaskOutputs', + '^buildProduction', +] const BUILD_INPUTS: TargetConfiguration['inputs'] = [ - 'production', - '^production', + 'buildProduction', + 'dependentTaskOutputs', + '^buildProduction', ] const PLAYWRIGHT_TOOLCHAINS = ['vite', 'rsbuild'] as const @@ -107,6 +112,33 @@ const PLAYWRIGHT_BUILD_COMMANDS: Record = { rsbuild: 'rsbuild build && tsc --noEmit', } +function captureCommandOutput(command: string, outputFile?: string): string { + if (!outputFile) { + return command + } + + if (!/^[a-zA-Z0-9._-]+$/.test(outputFile)) { + throw new Error( + `[Playwright Sharding Plugin] Invalid E2E_BUILD_LOG value: ${outputFile}. ` + + `Expected a filename containing only letters, numbers, dots, dashes, and underscores.`, + ) + } + + const escapedCommand = command.replaceAll("'", "'\\''") + return `bash -o pipefail -c '{ ${escapedCommand}; } 2>&1 | tee ${outputFile}'` +} + +function getTestOutputs( + modeKey: string, + portKey: string, +): TargetConfiguration['outputs'] { + return [ + `{projectRoot}/port-${portKey}*.txt`, + `{projectRoot}/test-results/${portKey}`, + `{projectRoot}/violations.${modeKey}.*.json`, + ] +} + type PlaywrightModeMetadata = { toolchain: PlaywrightToolchain mode: PlaywrightMode @@ -231,22 +263,28 @@ function buildModeTargets( const buildTargetName = `build:${modeMetadata.toolchain}:${modeMetadata.mode}${modeMetadata.name ? `:${modeMetadata.name}` : ''}` const shardCount = modeMetadata.shards ?? 1 const distDir = `dist-${modeMetadata.toolchain}-${modeMetadata.mode}${variantPathSuffix}` + const modeKey = `${modeMetadata.toolchain}-${modeMetadata.mode}${variantPathSuffix}` const modeEnv = { ...modeMetadata.env, MODE: modeMetadata.mode, TOOLCHAIN: modeMetadata.toolchain, E2E_TOOLCHAIN: modeMetadata.toolchain, + E2E_MODE_KEY: modeKey, E2E_DIST: distDir, E2E_DIST_DIR: distDir, } - const modePortKey = `${packageName}-${modeMetadata.toolchain}-${modeMetadata.mode}${variantPathSuffix}` + const buildLog = modeEnv.E2E_BUILD_LOG + const modePortKey = `${packageName}-${modeKey}-e2e` const modeDescription = `${modeMetadata.toolchain}/${modeMetadata.mode}${modeMetadata.name ? `/${modeMetadata.name}` : ''}` const modeShardTargets: Array = [] targets[buildTargetName] = { executor: 'nx:run-commands', options: { - command: PLAYWRIGHT_BUILD_COMMANDS[modeMetadata.toolchain], + command: captureCommandOutput( + PLAYWRIGHT_BUILD_COMMANDS[modeMetadata.toolchain], + buildLog, + ), cwd: projectRoot, env: modeEnv, }, @@ -254,7 +292,10 @@ function buildModeTargets( cache: true, inputs: BUILD_INPUTS, dependsOn: ['^build'], - outputs: [`{projectRoot}/${distDir}`], + outputs: [ + `{projectRoot}/${distDir}`, + ...(buildLog ? [`{projectRoot}/${buildLog}`] : []), + ], metadata: { technologies: ['playwright'], description: `Build artifacts for ${modeDescription} e2e tests`, @@ -266,7 +307,7 @@ function buildModeTargets( targets[modeTargetName] = { executor: 'nx:run-commands', options: { - command: 'playwright test --project=chromium', + command: `playwright test --project=chromium --output=test-results/${modePortKey}`, cwd: projectRoot, env: { ...modeEnv, @@ -275,6 +316,7 @@ function buildModeTargets( }, cache: true, inputs: TEST_INPUTS, + outputs: getTestOutputs(modeKey, modePortKey), dependsOn: [buildTargetName], metadata: { technologies: ['playwright'], @@ -290,7 +332,7 @@ function buildModeTargets( targets[shardTargetName] = { executor: 'nx:run-commands', options: { - command: `playwright test --project=chromium --shard=${shardIndex}/${shardCount}`, + command: `playwright test --project=chromium --shard=${shardIndex}/${shardCount} --output=test-results/${shardPortKey}`, cwd: projectRoot, env: { ...modeEnv, @@ -299,6 +341,7 @@ function buildModeTargets( }, cache: true, inputs: TEST_INPUTS, + outputs: getTestOutputs(modeKey, shardPortKey), dependsOn: [buildTargetName], metadata: { technologies: ['playwright'],