Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 22 additions & 2 deletions e2e/e2e-utils/src/fixture.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,36 @@
import { test as base, expect } from '@playwright/test'

export interface TestFixtureOptions {
/**
* List of error message patterns to ignore in console output.
* Supports both strings (substring match) and RegExp patterns.
*
* @example
* test.use({
* whitelistErrors: [
* 'Failed to load resource: net::ERR_NAME_NOT_RESOLVED',
* /Failed to load resource/,
* ],
* })
*/
whitelistErrors: Array<RegExp | string>
}
export const test = base.extend<TestFixtureOptions>({
whitelistErrors: [[], { option: true }],
whitelistErrors: [
// eslint-disable-next-line no-empty-pattern
async ({}, use) => {
await use([])
},
{ option: true },
],
page: async ({ page, whitelistErrors }, use) => {
const errorMessages: Array<string> = []
// Ensure whitelistErrors is always an array (defensive fallback)
const errors = Array.isArray(whitelistErrors) ? whitelistErrors : []
page.on('console', (m) => {
if (m.type() === 'error') {
const text = m.text()
for (const whitelistError of whitelistErrors) {
for (const whitelistError of errors) {
if (
(typeof whitelistError === 'string' &&
text.includes(whitelistError)) ||
Expand Down
2 changes: 1 addition & 1 deletion e2e/react-start/basic/tests/navigation.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { test } from '@tanstack/router-e2e-utils'

test.use({
whitelistErrors: [
/Failed to load resource: the server responded with a status of 404/,
'Failed to load resource: the server responded with a status of 404',
],
})
test('Navigating to post', async ({ page }) => {
Expand Down
2 changes: 1 addition & 1 deletion e2e/react-start/basic/tests/not-found.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ const combinate = (combinateImport as any).default as typeof combinateImport

test.use({
whitelistErrors: [
/Failed to load resource: the server responded with a status of 404/,
'Failed to load resource: the server responded with a status of 404',
'NotFound error during hydration for routeId',
],
})
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { test } from '@tanstack/router-e2e-utils'

test.use({
whitelistErrors: [
/Failed to load resource: the server responded with a status of 404/,
'Failed to load resource: the server responded with a status of 404',
],
})

Expand Down
2 changes: 1 addition & 1 deletion e2e/react-start/basic/tests/params.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ test.beforeEach(async ({ page }) => {

test.use({
whitelistErrors: [
/Failed to load resource: the server responded with a status of 404/,
'Failed to load resource: the server responded with a status of 404',
],
})
test.describe('Unicode route rendering', () => {
Expand Down
25 changes: 20 additions & 5 deletions e2e/react-start/css-modules/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,29 +6,44 @@
"scripts": {
"dev": "vite dev --port 3000",
"dev:e2e": "vite dev --port $PORT",
"dev:nitro": "VITE_USE_NITRO=true vite dev --port 3000",
"dev:e2e:nitro": "VITE_USE_NITRO=true vite dev --port $PORT",
"dev:basepath": "VITE_BASE_PATH=/my-app vite dev --port 3000",
"dev:e2e:basepath": "VITE_BASE_PATH=/my-app vite dev --port $PORT",
"dev:cloudflare": "echo 'Cloudflare dev mode has React duplication issues - use build+preview instead' && exit 1",
"dev:e2e:cloudflare": "echo 'Cloudflare dev mode has React duplication issues - use build+preview instead' && exit 1",
"build": "vite build && tsc --noEmit",
"preview": "vite preview",
"start": "pnpx srvx --prod -s ../client dist/server/server.js",
"test:e2e:dev": "MODE=dev playwright test --project=chromium",
"test:e2e:dev:nitro": "MODE=dev VITE_CONFIG=nitro playwright test --project=chromium",
"test:e2e:dev:basepath": "MODE=dev VITE_CONFIG=basepath playwright test --project=chromium",
"_test:e2e:dev:cloudflare": "MODE=dev VITE_CONFIG=cloudflare playwright test --project=chromium",
"test:e2e:prod": "playwright test --project=chromium",
"test:e2e": "rm -rf port*.txt; pnpm run test:e2e:dev"
"test:e2e": "rm -rf port*.txt; pnpm run test:e2e:dev",
"test:e2e:nitro": "rm -rf port*.txt; pnpm run test:e2e:dev:nitro",
"test:e2e:basepath": "rm -rf port*.txt; pnpm run test:e2e:dev:basepath",
"_test:e2e:cloudflare": "echo 'Cloudflare dev mode disabled - React duplication issues' && exit 0"
},
"dependencies": {
"@tanstack/react-router": "workspace:^",
"@tanstack/react-start": "workspace:^",
"@tanstack/react-router": "workspace:*",
"@tanstack/react-start": "workspace:*",
"react": "^19.0.0",
"react-dom": "^19.0.0"
},
"devDependencies": {
"@cloudflare/vite-plugin": "^1.15.1",
"@playwright/test": "^1.50.1",
"@tanstack/router-e2e-utils": "workspace:^",
"@tanstack/router-e2e-utils": "workspace:*",
"@types/node": "^22.10.2",
"@types/react": "^19.0.8",
"@types/react-dom": "^19.0.3",
"@vitejs/plugin-react": "^4.3.4",
"nitro": "npm:nitro-nightly@latest",
"srvx": "^0.10.0",
"typescript": "^5.7.2",
"vite": "^7.1.7",
"vite-tsconfig-paths": "^5.1.4"
"vite-tsconfig-paths": "^5.1.4",
"wrangler": "^4.22.0"
}
}
18 changes: 14 additions & 4 deletions e2e/react-start/css-modules/playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,19 @@ import { defineConfig, devices } from '@playwright/test'
import { getTestServerPort } from '@tanstack/router-e2e-utils'
import packageJson from './package.json' with { type: 'json' }

const isDev = process.env.MODE === 'dev'
const PORT = await getTestServerPort(packageJson.name)
const baseURL = `http://localhost:${PORT}`
const mode = process.env.MODE ?? 'prod'
const isDev = mode === 'dev'
const viteConfig = process.env.VITE_CONFIG // 'nitro' | 'basepath' | 'cloudflare' | undefined
const PORT = await getTestServerPort(
viteConfig ? `${packageJson.name}-${viteConfig}` : packageJson.name,
)

// When using basepath config, the app is served at /my-app
const basePath = viteConfig === 'basepath' ? '/my-app' : ''
const baseURL = `http://localhost:${PORT}${basePath}`

// Select the appropriate dev command based on VITE_CONFIG
const devCommand = viteConfig ? `pnpm dev:e2e:${viteConfig}` : 'pnpm dev:e2e'

Comment on lines +12 to 18
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

cat -n e2e/react-start/css-modules/playwright.config.ts

Repository: TanStack/router

Length of output: 1735


🏁 Script executed:

find . -name "package.json" -path "*/e2e/react-start/css-modules/*" -exec cat {} \;

Repository: TanStack/router

Length of output: 2276


Ensure VITE_CONFIG environment variable is propagated to webServer.env for explicit contract clarity.

The baseURL and devCommand are both derived from process.env.VITE_CONFIG (lines 12, 16), creating an implicit contract that callers must set VITE_CONFIG consistently. However, VITE_CONFIG is not passed to webServer.env (lines 35–37), making this contract invisible to the spawned dev server. Add VITE_CONFIG to webServer.env to clarify the dependency and prevent accidental misconfiguration when running locally:

webServer.env block (lines 35–37)
    env: {
      VITE_NODE_ENV: 'test',
      PORT: String(PORT),
      VITE_CONFIG: viteConfig || '',
    },
🤖 Prompt for AI Agents
In @e2e/react-start/css-modules/playwright.config.ts around lines 11 - 17, The
test harness builds baseURL and devCommand from process.env.VITE_CONFIG (via the
local viteConfig variable) but does not propagate that variable into the
Playwright webServer.env, making the dev server unaware of the same config;
update the webServer.env block in playwright.config.ts (the webServer
configuration object) to include VITE_CONFIG with the same value you compute for
viteConfig (e.g. VITE_CONFIG: viteConfig || ''), so the spawned dev server
receives the same VITE_CONFIG used to construct baseURL and devCommand.

export default defineConfig({
testDir: './tests',
Expand All @@ -19,7 +29,7 @@ export default defineConfig({
},

webServer: {
command: isDev ? `pnpm dev:e2e` : `pnpm build && PORT=${PORT} pnpm start`,
command: isDev ? devCommand : `pnpm build && PORT=${PORT} pnpm start`,
url: baseURL,
reuseExistingServer: !process.env.CI,
stdout: 'pipe',
Expand Down
119 changes: 79 additions & 40 deletions e2e/react-start/css-modules/tests/css.spec.ts
Original file line number Diff line number Diff line change
@@ -1,31 +1,54 @@
import { expect, request } from '@playwright/test'
import { expect } from '@playwright/test'
import { test } from '@tanstack/router-e2e-utils'

// Whitelist errors that can occur in CI:
// - net::ERR_NAME_NOT_RESOLVED: transient network issues
// - 504 (Outdated Optimize Dep): Vite dependency optimization reload
const whitelistErrors = [
'Failed to load resource: net::ERR_NAME_NOT_RESOLVED',
'Failed to load resource: the server responded with a status of 504',
]

test.describe('CSS styles in SSR (dev mode)', () => {
test.use({ whitelistErrors })

// Warmup: trigger Vite's dependency optimization before running tests
// This prevents "504 (Outdated Optimize Dep)" errors during actual tests
test.beforeAll(async ({ baseURL }) => {
const context = await request.newContext()
// This prevents "optimized dependencies changed. reloading" during actual tests
// We use a real browser context since dep optimization happens on JS load, not HTTP requests
test.beforeAll(async ({ browser, baseURL }) => {
const context = await browser.newContext()
const page = await context.newPage()
try {
// Hit both pages to trigger any dependency optimization
await context.get(baseURL!)
await context.get(`${baseURL}/modules`)
// Give Vite time to complete optimization
await new Promise((resolve) => setTimeout(resolve, 1000))
// Hit again after optimization
await context.get(baseURL!)
// Load both pages to trigger dependency optimization
await page.goto(baseURL!)
await page.waitForTimeout(2000) // Wait for deps to optimize
await page.goto(`${baseURL}/modules`)
await page.waitForTimeout(2000)
// Load again after optimization completes
await page.goto(baseURL!)
await page.waitForTimeout(1000)
} catch {
// Ignore errors during warmup
} finally {
await context.dispose()
await context.close()
}
})

// Helper to build full URL from baseURL and path
// Playwright's goto with absolute paths (like '/modules') ignores baseURL's path portion
// So we need to manually construct the full URL
const buildUrl = (baseURL: string, path: string) => {
return baseURL.replace(/\/$/, '') + path
}

test.describe('with JavaScript disabled', () => {
test.use({ javaScriptEnabled: false })
test.use({ javaScriptEnabled: false, whitelistErrors })

test('global CSS is applied on initial page load', async ({ page }) => {
await page.goto('/')
test('global CSS is applied on initial page load', async ({
page,
baseURL,
}) => {
await page.goto(buildUrl(baseURL!, '/'))

const element = page.getByTestId('global-styled')
await expect(element).toBeVisible()
Expand All @@ -48,8 +71,11 @@ test.describe('CSS styles in SSR (dev mode)', () => {
expect(borderRadius).toBe('12px')
})

test('CSS modules are applied on initial page load', async ({ page }) => {
await page.goto('/modules')
test('CSS modules are applied on initial page load', async ({
page,
baseURL,
}) => {
await page.goto(buildUrl(baseURL!, '/modules'))

const card = page.getByTestId('module-card')
await expect(card).toBeVisible()
Expand Down Expand Up @@ -77,8 +103,8 @@ test.describe('CSS styles in SSR (dev mode)', () => {
expect(borderRadius).toBe('8px')
})

test('global CSS class names are NOT scoped', async ({ page }) => {
await page.goto('/')
test('global CSS class names are NOT scoped', async ({ page, baseURL }) => {
await page.goto(buildUrl(baseURL!, '/'))

const element = page.getByTestId('global-styled')
await expect(element).toBeVisible()
Expand All @@ -89,37 +115,47 @@ test.describe('CSS styles in SSR (dev mode)', () => {
})
})

test('styles persist after hydration', async ({ page }) => {
await page.goto('/')

// Wait for hydration
await page.waitForTimeout(1000)
test('styles persist after hydration', async ({ page, baseURL }) => {
await page.goto(buildUrl(baseURL!, '/'))

// Wait for hydration and styles to be applied
const element = page.getByTestId('global-styled')
const backgroundColor = await element.evaluate(
(el) => getComputedStyle(el).backgroundColor,
)
expect(backgroundColor).toBe('rgb(59, 130, 246)')
})
await expect(element).toBeVisible()

test('CSS modules styles persist after hydration', async ({ page }) => {
await page.goto('/modules')
// Wait for CSS to be applied (background color should not be transparent)
await expect(async () => {
const backgroundColor = await element.evaluate(
(el) => getComputedStyle(el).backgroundColor,
)
expect(backgroundColor).toBe('rgb(59, 130, 246)')
}).toPass({ timeout: 5000 })
})

// Wait for hydration
await page.waitForTimeout(1000)
test('CSS modules styles persist after hydration', async ({
page,
baseURL,
}) => {
await page.goto(buildUrl(baseURL!, '/modules'))

// Wait for hydration and styles to be applied
const card = page.getByTestId('module-card')
const backgroundColor = await card.evaluate(
(el) => getComputedStyle(el).backgroundColor,
)
expect(backgroundColor).toBe('rgb(240, 253, 244)')
await expect(card).toBeVisible()

// Wait for CSS to be applied (background color should not be transparent)
await expect(async () => {
const backgroundColor = await card.evaluate(
(el) => getComputedStyle(el).backgroundColor,
)
expect(backgroundColor).toBe('rgb(240, 253, 244)')
}).toPass({ timeout: 5000 })
})

test('styles work correctly after client-side navigation', async ({
page,
baseURL,
}) => {
// Start from home
await page.goto('/')
await page.goto(buildUrl(baseURL!, '/'))
await page.waitForTimeout(1000)

// Verify initial styles
Expand All @@ -132,7 +168,8 @@ test.describe('CSS styles in SSR (dev mode)', () => {

// Navigate to modules page
await page.getByTestId('nav-modules').click()
await page.waitForURL('/modules')
// Use glob pattern to match with or without basepath
await page.waitForURL('**/modules')

// Verify CSS modules styles
const card = page.getByTestId('module-card')
Expand All @@ -144,7 +181,9 @@ test.describe('CSS styles in SSR (dev mode)', () => {

// Navigate back to home
await page.getByTestId('nav-home').click()
await page.waitForURL('/')
// Match home URL with or without trailing slash and optional query string
// Matches: /, /?, /my-app, /my-app/, /my-app?foo=bar
await page.waitForURL(/\/([^/]*)(\/)?($|\?)/)

// Verify global styles still work
await expect(globalElement).toBeVisible()
Expand Down
16 changes: 16 additions & 0 deletions e2e/react-start/css-modules/vite.config.cloudflare.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { defineConfig } from 'vite'
import tsConfigPaths from 'vite-tsconfig-paths'
import { cloudflare } from '@cloudflare/vite-plugin'
import { tanstackStart } from '@tanstack/react-start/plugin/vite'
import viteReact from '@vitejs/plugin-react'

export default defineConfig({
plugins: [
tsConfigPaths({
projects: ['./tsconfig.json'],
}),
cloudflare({ viteEnvironment: { name: 'ssr' }, inspectorPort: false }),
tanstackStart(),
viteReact(),
],
})
35 changes: 24 additions & 11 deletions e2e/react-start/css-modules/vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,28 @@ import tsConfigPaths from 'vite-tsconfig-paths'
import { tanstackStart } from '@tanstack/react-start/plugin/vite'
import viteReact from '@vitejs/plugin-react'

export default defineConfig({
server: {
port: 3000,
},
plugins: [
tsConfigPaths({
projects: ['./tsconfig.json'],
}),
tanstackStart(),
viteReact(),
],
// Environment variables for different test configurations:
// - VITE_BASE_PATH: Set to '/my-app' for basepath testing
// - VITE_USE_NITRO: Set to 'true' to enable Nitro server
const basePath = process.env.VITE_BASE_PATH
const useNitro = process.env.VITE_USE_NITRO === 'true'

export default defineConfig(async () => {
// Dynamically import nitro only when needed to avoid loading it when not used
const nitroPlugin = useNitro ? [(await import('nitro/vite')).nitro()] : []

return {
base: basePath,
server: {
port: 3000,
},
plugins: [
tsConfigPaths({
projects: ['./tsconfig.json'],
}),
tanstackStart(),
viteReact(),
...nitroPlugin,
],
}
})
Loading
Loading