Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
33a36d2
fix: stabilize integration test suite
jherr Apr 24, 2026
1a263ab
style: fix prettier formatting
jherr Apr 24, 2026
a8f885f
Merge remote-tracking branch 'origin/main' into fix/integration-test-…
jherr May 27, 2026
5476051
chore: pin @netlify/blobs to 10.7.0 to bisect serve-mode flake
jherr May 27, 2026
42642f6
chore: bisect — pin @netlify/build to 35.13.3, revert blobs pin
jherr May 27, 2026
b3f369f
chore: bisect — pin all @netlify/* to pre-merge versions
jherr May 27, 2026
60d6c8a
chore: bisect — pin @fastify/static to 9.0.0, revert all @netlify pins
jherr May 27, 2026
91b1d39
test(integration): instrument dev-server util to diagnose node 24 hang
jherr May 27, 2026
c48bc05
test(integration): dump node diagnostic report on dev-server hang
jherr May 27, 2026
678f748
test(integration): drop NODE_OPTIONS flags rejected by Worker threads
jherr May 27, 2026
a95c20e
debug: instrument serve.ts with per-step stderr markers
jherr May 27, 2026
236e745
debug: narrow the hang inside startFunctionsServer
jherr May 27, 2026
2077c6f
debug: narrow hang inside FunctionsRegistry.scan
jherr May 27, 2026
7c3f178
fix: lint - stringify number in diagnose template
jherr May 27, 2026
ed55775
debug: instrument registerFunction to narrow hang past unregisterFunc…
jherr May 27, 2026
1e7c583
fix: replace extract-zip with node-stream-zip to fix Node 24 hang
jherr May 28, 2026
08f0ec1
fix: rewrite zip extractor on yauzl + stream/promises
jherr May 28, 2026
56af0b7
chore: remove diagnostic markers and drop extract-zip
jherr May 28, 2026
fb18cdc
style: prettier formatting in dev-server.ts
jherr May 28, 2026
05ea557
fix(test): tighten startServer return type so timeout fields are valid
jherr May 28, 2026
7b862e0
chore: split out zip-extractor dependency change
jherr May 28, 2026
d774b94
Merge branch 'main' into fix/integration-test-flakes-pr
serhalp May 29, 2026
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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ site/src/**/*.md
.verdaccio-storage
.eslintcache
_test_out/**
tests/unit/utils/tmp
*.crt
*.key

Expand Down
2 changes: 1 addition & 1 deletion src/utils/telemetry/report-error.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ const dirPath = dirname(fileURLToPath(import.meta.url))
*/
// @ts-expect-error TS(7006) FIXME: Parameter 'error' implicitly has an 'any' type.
export const reportError = async function (error, config = {}) {
if (isCI) {
if (isCI || process.env.CI) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

IIRC isCI already checks CI env var as part of its checks, so I am not sure if this actually does anything? Ref https://github.com/watson/ci-info/blob/c4e1d0565552fb20ea3c133db2e056a574e78e6b/test.js#L59-L65

return
}
// convert a NotifiableError to an error class
Expand Down
11 changes: 7 additions & 4 deletions tests/integration/commands/dev/dev.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -382,14 +382,15 @@ describe.concurrent('command/dev', () => {
t.expect(nodeVer).toBeGreaterThanOrEqual(18)

await withSiteBuilder(t, async (builder) => {
const targetPort = await getPort()
const externalServer = startExternalServer({
host: '127.0.0.1',
port: 4567,
port: targetPort,
})
await builder.build()

await withDevServer(
{ cwd: builder.directory, command: 'node', framework: '#custom', targetPort: 4567, skipWaitPort: true },
{ cwd: builder.directory, command: 'node', framework: '#custom', targetPort, skipWaitPort: true },
async (server) => {
const response = await fetch(`${server.url}/test`)
t.expect(response.status).toBe(200)
Expand Down Expand Up @@ -828,13 +829,15 @@ describe.concurrent('command/dev', () => {

test('deploy environment variables injected by onDev plugin hooks are injected into functions', async (t) => {
await withSiteBuilder(t, async (builder) => {
const targetPort = await getPort()

await builder
.withNetlifyToml({
config: {
plugins: [{ package: './plugins/plugin' }],
dev: {
command: 'node index.mjs',
targetPort: 4445,
targetPort,
},
},
})
Expand Down Expand Up @@ -889,7 +892,7 @@ describe.concurrent('command/dev', () => {
res.end();
})

server.listen(4445)
server.listen(${targetPort.toString()})
`,
})
.build()
Expand Down
68 changes: 47 additions & 21 deletions tests/integration/commands/dev/redirects.test.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
import { readFile, writeFile } from 'fs/promises'
import { join } from 'path'

import getPort from 'get-port'
import fetch from 'node-fetch'
import { describe, expect, test } from 'vitest'
Expand Down Expand Up @@ -32,27 +35,50 @@ describe('redirects', async () => {
})
})

await setupFixtureTests('next-app', { devServer: { env: { NETLIFY_DEV_SERVER_CHECK_SSG_ENDPOINTS: 1 } } }, () => {
test<FixtureTestContext>('should prefer local files instead of redirect when not forced', async ({ devServer }) => {
const response = await fetch(`http://localhost:${devServer!.port}/test.txt`, {})

expect(response.status).toBe(200)

const result = await response.text()
expect(result.trim()).toEqual('hello world')
})

test<FixtureTestContext>('should check for the dynamic page existence before doing redirect', async ({
devServer,
}) => {
const response = await fetch(`http://localhost:${devServer!.port}/`, {})

expect(response.status).toBe(200)

const result = await response.text()
expect(result.toLowerCase()).not.toContain('netlify')
})
})
await setupFixtureTests(
'next-app',
{
devServer: { env: { NETLIFY_DEV_SERVER_CHECK_SSG_ENDPOINTS: 1 } },
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Env value should be a string for consistency and to match NodeJS.ProcessEnv typing.

Line 122 of this same file uses NETLIFY_DEV_SERVER_CHECK_SSG_ENDPOINTS: '1'. Node coerces numeric values at spawn time, but TS's ProcessEnv type expects strings and downstream code typically does strict equality against '1'.

-      devServer: { env: { NETLIFY_DEV_SERVER_CHECK_SSG_ENDPOINTS: 1 } },
+      devServer: { env: { NETLIFY_DEV_SERVER_CHECK_SSG_ENDPOINTS: '1' } },
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
devServer: { env: { NETLIFY_DEV_SERVER_CHECK_SSG_ENDPOINTS: 1 } },
devServer: { env: { NETLIFY_DEV_SERVER_CHECK_SSG_ENDPOINTS: '1' } },
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/integration/commands/dev/redirects.test.ts` at line 41, The env value
for NETLIFY_DEV_SERVER_CHECK_SSG_ENDPOINTS is currently a number; change it to a
string to match NodeJS.ProcessEnv and existing tests (use '1' instead of 1).
Locate the devServer config object (devServer: { env: {
NETLIFY_DEV_SERVER_CHECK_SSG_ENDPOINTS: ... } }) in the test and update the
value to '1' so it matches the usage on line with
NETLIFY_DEV_SERVER_CHECK_SSG_ENDPOINTS: '1' and any strict equality checks
elsewhere.

setup: async ({ fixture }) => {
const targetPort = await getPort()
const packageJsonPath = join(fixture.directory, 'package.json')
const netlifyTomlPath = join(fixture.directory, 'netlify.toml')
const packageJson = JSON.parse(await readFile(packageJsonPath, 'utf8')) as { scripts: { dev: string } }

packageJson.scripts.dev = `next dev -p ${targetPort.toString()}`
await writeFile(packageJsonPath, `${JSON.stringify(packageJson, null, 2)}\n`)
await writeFile(
netlifyTomlPath,
(
await readFile(netlifyTomlPath, 'utf8')
).replace('targetPort = 6123', `targetPort = ${targetPort.toString()}`),
)
Comment on lines +50 to +55
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Fragile string replace — silently no-ops if targetPort = 6123 is ever reformatted.

String.prototype.replace with a literal needle returns the original string unchanged when no match is found, so a future reformat (e.g., targetPort=6123, different whitespace, or bumping the placeholder value) would leave netlify.toml pointing at the old port while next dev listens on the dynamic one — and the failure mode is a mysterious timeout rather than a clear error.

🛠️ Suggested fix: assert the replacement happened (or use a regex with a post-check)
-        await writeFile(
-          netlifyTomlPath,
-          (await readFile(netlifyTomlPath, 'utf8')).replace('targetPort = 6123', `targetPort = ${targetPort.toString()}`),
-        )
+        const originalToml = await readFile(netlifyTomlPath, 'utf8')
+        const updatedToml = originalToml.replace(/targetPort\s*=\s*\d+/, `targetPort = ${targetPort.toString()}`)
+        if (updatedToml === originalToml) {
+          throw new Error(`Failed to substitute targetPort in ${netlifyTomlPath}`)
+        }
+        await writeFile(netlifyTomlPath, updatedToml)
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
await writeFile(
netlifyTomlPath,
(await readFile(netlifyTomlPath, 'utf8')).replace('targetPort = 6123', `targetPort = ${targetPort.toString()}`),
)
const originalToml = await readFile(netlifyTomlPath, 'utf8')
const updatedToml = originalToml.replace(/targetPort\s*=\s*\d+/, `targetPort = ${targetPort.toString()}`)
if (updatedToml === originalToml) {
throw new Error(`Failed to substitute targetPort in ${netlifyTomlPath}`)
}
await writeFile(netlifyTomlPath, updatedToml)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/integration/commands/dev/redirects.test.ts` around lines 50 - 53, The
current test uses a fragile literal string replace on the netlify toml contents
via readFile/netlifyTomlPath and writeFile which will silently no-op if
formatting changes; update the logic in the test to perform the replacement with
a regex (matching variations like whitespace around `targetPort` and the value)
or run the replace and then assert that the result differs from the original
(using targetPort.toString() as the new value), and if no change occurred
throw/assert a failure before calling writeFile so the test fails loudly rather
than leaving netlify.toml unchanged.

},
},
() => {
test<FixtureTestContext>('should prefer local files instead of redirect when not forced', async ({
devServer,
}) => {
const response = await fetch(`http://localhost:${devServer!.port}/test.txt`, {})

expect(response.status).toBe(200)

const result = await response.text()
expect(result.trim()).toEqual('hello world')
})

test<FixtureTestContext>('should check for the dynamic page existence before doing redirect', async ({
devServer,
}) => {
const response = await fetch(`http://localhost:${devServer!.port}/`, {})

expect(response.status).toBe(200)

const result = await response.text()
expect(result.toLowerCase()).not.toContain('netlify')
})
},
)
Comment thread
coderabbitai[bot] marked this conversation as resolved.

test('should not check the endpoint existence for hidden proxies', async (t) => {
await withSiteBuilder(t, async (builder) => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,19 +17,18 @@ import {
createAIGatewayTestData,
} from '../../utils/ai-gateway-helpers.js'

const DEFAULT_PORT = 9999
const SERVE_TIMEOUT = 180_000

const withFunctionsServer = async (
{
args = [],
builder,
port = DEFAULT_PORT,
port,
env = {},
}: {
args?: string[]
builder: SiteBuilder
port?: number
port: number
env?: NodeJS.ProcessEnv
},
testHandler: () => Promise<unknown>,
Expand Down Expand Up @@ -74,8 +73,9 @@ describe.concurrent('functions:serve command', () => {
})
.build()

await withFunctionsServer({ builder }, async () => {
const response = await fetch(`http://localhost:9999/.netlify/functions/ping`)
const port = await getPort()
await withFunctionsServer({ builder, args: ['--port', port.toString()], port }, async () => {
const response = await fetch(`http://localhost:${port.toString()}/.netlify/functions/ping`)
t.expect(await response.text()).toEqual('ping')
})
})
Expand Down
23 changes: 17 additions & 6 deletions tests/integration/framework-detection.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import execa from 'execa'
Comment thread
coderabbitai[bot] marked this conversation as resolved.
import getPort from 'get-port'
import fetch from 'node-fetch'
import { describe, test } from 'vitest'

Expand Down Expand Up @@ -89,6 +90,7 @@ describe.concurrent('frameworks/framework-detection', () => {

test('should warn if using static server and `targetPort` is configured', async (t) => {
await withSiteBuilder(t, async (builder) => {
const targetPort = await getPort()
await builder
.withContentFile({
path: 'public/index.html',
Expand All @@ -97,7 +99,7 @@ describe.concurrent('frameworks/framework-detection', () => {
.build()

await withDevServer(
{ cwd: builder.directory, args: ['--dir', 'public', '--target-port', '3000'] },
{ cwd: builder.directory, args: ['--dir', 'public', '--target-port', targetPort.toString()] },
async ({ output, url }) => {
const response = await fetch(url)
const responseContent = await response.text()
Expand All @@ -111,11 +113,12 @@ describe.concurrent('frameworks/framework-detection', () => {

test('should run `command` when both `command` and `targetPort` are configured', async (t) => {
await withSiteBuilder(t, async (builder) => {
const targetPort = await getPort()
await builder.withNetlifyToml({ config: { build: { publish: 'public' } } }).build()

try {
await withDevServer(
{ cwd: builder.directory, args: ['--command', 'echo hello', '--target-port', '3000'] },
{ cwd: builder.directory, args: ['--command', 'echo hello', '--target-port', targetPort.toString()] },
async () => {},
true,
)
Expand Down Expand Up @@ -185,10 +188,15 @@ describe.concurrent('frameworks/framework-detection', () => {

test('should throw if framework=#custom but command is missing', async (t) => {
await withSiteBuilder(t, async (builder) => {
const targetPort = await getPort()
await builder.withNetlifyToml({ config: { dev: { framework: '#custom' } } }).build()

try {
await withDevServer({ cwd: builder.directory, args: ['--target-port', '3000'] }, async () => {}, true)
await withDevServer(
{ cwd: builder.directory, args: ['--target-port', targetPort.toString()] },
async () => {},
true,
)
t.expect.unreachable()
} catch (err) {
t.expect(err).toHaveProperty('stdout')
Expand Down Expand Up @@ -217,11 +225,12 @@ describe.concurrent('frameworks/framework-detection', () => {

test('should start custom command if framework=#custom, command and targetPort are configured', async (t) => {
await withSiteBuilder(t, async (builder) => {
const targetPort = await getPort()
await builder.withNetlifyToml({ config: { dev: { framework: '#custom', publish: 'public' } } }).build()

try {
await withDevServer(
{ cwd: builder.directory, args: ['--command', 'echo hello', '--target-port', '3000'] },
{ cwd: builder.directory, args: ['--command', 'echo hello', '--target-port', targetPort.toString()] },
async () => {},
true,
)
Expand All @@ -237,6 +246,7 @@ describe.concurrent('frameworks/framework-detection', () => {

test(`should print specific error when command doesn't exist`, async (t) => {
await withSiteBuilder(t, async (builder) => {
const targetPort = await getPort()
await builder.build()

try {
Expand All @@ -247,7 +257,7 @@ describe.concurrent('frameworks/framework-detection', () => {
'--command',
'oops-i-did-it-again forgot-to-use-a-valid-command',
'--target-port',
'3000',
targetPort.toString(),
'--framework',
'#custom',
],
Expand Down Expand Up @@ -345,11 +355,12 @@ describe.concurrent('frameworks/framework-detection', () => {

test('should not run framework detection if command and targetPort are configured', async (t) => {
await withSiteBuilder(t, async (builder) => {
const targetPort = await getPort()
await builder.withContentFile({ path: 'config.toml', content: '' }).build()

try {
await withDevServer(
{ cwd: builder.directory, args: ['--command', 'echo hello', '--target-port', '3000'] },
{ cwd: builder.directory, args: ['--command', 'echo hello', '--target-port', targetPort.toString()] },
async () => {},
true,
)
Expand Down
82 changes: 73 additions & 9 deletions tests/integration/utils/dev-server.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { readdir, readFile } from 'node:fs/promises'
import path from 'node:path'
import process from 'node:process'

Expand All @@ -14,10 +15,21 @@ export const getExecaOptions = ({ cwd, env }: { cwd: string; env: NodeJS.Process

const { LANG, LC_ALL, ...baseEnv } = process.env

// Diagnostics for the Node 24 serve-mode hang. The trace flags surface any
// uncaught exception, deprecation warning, or process.exit. --report-on-signal
// makes Node emit a full diagnostic report (stacks + libuv handles) on SIGUSR2,
// written to a file in cwd. The test util sends SIGUSR2 just before the
// start-timeout fires so the report captures *what* the process is stuck on.
// Note: --report-on-fatalerror, --report-uncaught-exception, and
// --report-filename are NOT permitted in NODE_OPTIONS (Worker threads reject
// env vars containing them), so we keep this list to the allowed subset.
const traceFlags = '--trace-warnings --trace-uncaught --trace-exit --report-on-signal --report-signal=SIGUSR2'
const nodeOptions = baseEnv.NODE_OPTIONS ? `${baseEnv.NODE_OPTIONS} ${traceFlags}` : traceFlags

return {
cwd,
extendEnv: false,
env: { ...baseEnv, BROWSER: 'none', ...env },
env: { ...baseEnv, BROWSER: 'none', NODE_OPTIONS: nodeOptions, ...env },
encoding: 'utf8',
}
}
Expand Down Expand Up @@ -70,7 +82,7 @@ const startServer = async ({
serve = false,
skipWaitPort = false,
targetPort,
}: DevServerOptions): Promise<DevServer | { timeout: boolean; output: string }> => {
}: DevServerOptions): Promise<DevServer | { timeout: boolean; output: string; error?: string; report?: string }> => {
const port = await getPort()
const host = 'localhost'
const url = `http://${host}:${port}`
Expand Down Expand Up @@ -171,12 +183,62 @@ const startServer = async ({
})
}
})
ps.catch((error) => !selfKilled && reject(error))
// The subprocess can exit cleanly (code 0) without ever emitting
// "Local dev server ready" — when that happens the promise above would hang until
// SERVER_START_TIMEOUT. Detect both clean and error exits and reject with the
// captured output so the failure is visible immediately.
ps.then(
(result) => {
if (!selfKilled) {
reject(
new Error(
`Dev server subprocess exited (code=${
result.exitCode
}) before "Local dev server ready" was emitted.\nstdout:\n${outputBuffer.join(
'',
)}\nstderr:\n${errorBuffer.join('')}`,
),
)
}
},
(error: unknown) => {
if (!selfKilled) {
reject(error instanceof Error ? error : new Error(String(error)))
}
},
)
})

return await pTimeout(serverPromise, {
milliseconds: SERVER_START_TIMEOUT,
fallback: () => ({ timeout: true, output: outputBuffer.join('') }),
fallback: async () => {
// Ask the (presumed-hung) subprocess to write a Node diagnostic report
// (stack traces + libuv handle state — i.e. *what* the process is stuck on).
// The report is written to a file in the subprocess's cwd; we read it
// back below.
let diagnosticReport = ''
if (ps.pid != null && !ps.killed) {
try {
process.kill(ps.pid, 'SIGUSR2')
await new Promise((resolve) => setTimeout(resolve, 2_000))
const entries = await readdir(cwd)
const reports = entries.filter((name) => name.startsWith('report.') && name.endsWith('.json')).sort()
if (reports.length > 0) {
const last = reports[reports.length - 1]
diagnosticReport = await readFile(path.join(cwd, last), 'utf8')
}
} catch {
// process may have already exited, or the report write may have failed —
// either way we want to fall through and surface what we have
}
}
return {
timeout: true,
output: outputBuffer.join(''),
error: errorBuffer.join(''),
report: diagnosticReport,
}
},
})
}

Expand All @@ -187,12 +249,14 @@ export const startDevServer = async (options: DevServerOptions, expectFailure?:
try {
// do not use destruction, as we use getters which otherwise would be evaluated here
const devServer = await startServer({ ...options, expectFailure })
// @ts-expect-error TS(2339) FIXME: Property 'timeout' does not exist on type 'DevServ... Remove this comment to see the full error message
if (devServer.timeout) {
throw new Error(`Timed out starting dev server.\nServer Output:\n${devServer.output}`)
if ('timeout' in devServer && devServer.timeout) {
throw new Error(
`Timed out starting dev server.\nServer Output:\n${devServer.output}\nServer Error:\n${
devServer.error ?? ''
}\nDiagnostic Report:\n${devServer.report ?? '(none captured)'}`,
)
}
// @ts-expect-error TS(2322) FIXME: Type 'DevServer | { timeout: boolean; output: stri... Remove this comment to see the full error message
return devServer
return devServer as DevServer
} catch (error) {
if (attempt === maxAttempts || expectFailure) {
throw error
Expand Down
Loading
Loading