test: regression coverage for hook.resume() from isolated route bundles (o2flow beta.26 incident) (#3001)

* test: regression coverage for hook.resume() from isolated route bundles (o2flow beta.26 incident)

Reproduces the o2flow v5 upgrade failure (workflow@5.0.0-beta.26, fixed by
#2752 in beta.28): a plain API route importing defineHook() from the root
`workflow` entry and calling .resume() failed with Turbopack's
"Cannot find module as expression is too dynamic" stub, because the world
registration was tree-shaken out of the route bundle and getWorldLazy()'s
dynamic-import fallback got stubbed.

The bug only manifests when a route bundle loads in isolation (a Vercel
lambda): local `next dev`/`next start` evaluates next.config.ts, whose
workflow/next import chain registers the world process-wide and masks it —
which is why no existing server-driven suite caught it.

- route-bundle-isolation.test.ts: production Turbopack build of the
  nextjs-turbopack workbench, then loads ONLY the compiled route bundle in a
  bare Node subprocess (cold-lambda simulation) and invokes its POST handler.
  Fails with the exact incident error on regressed code; passes on main.
  Wired into the build-error-messages CI job.
- e2e: plainModuleDoneHook round-trip through a plain API route on the two
  Next workbenches (deployed matrix covers real lambda isolation).
- Workbench fixtures mirroring o2flow: a directive-less defineHook module
  shared by a workflow (create) and a plain route (resume). The webpack
  workbench gets a real route file because `next dev` (webpack) does not
  serve directory-symlinked app routes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Pranay Prakash <pranay.gp@gmail.com>

* test: authenticate plain hook resume request

* test: address review — marker-based harness output parsing, changeset summary

- route-bundle-isolation: prefix the harness result line with a unique
  marker and locate it explicitly instead of JSON.parse()ing the last
  stdout line, so stray logging from the route bundle or the world can't
  break parsing; failures now include the full subprocess stdout.
- changeset: add a human-readable summary to the (release-less) changeset.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Pranay Prakash <pranay.gp@gmail.com>

---------

Signed-off-by: Pranay Prakash <pranay.gp@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Karthik Kalyanaraman <karthik.kalyanaraman@vercel.com>
Co-authored-by: Karthik Kalyan <105607645+karthikscale3@users.noreply.github.com>
This commit is contained in:
Pranay Prakash
2026-07-21 13:24:17 +07:00
committed by GitHub
parent 2b63c6ed72
commit 9a2770ab34
12 changed files with 357 additions and 0 deletions
@@ -0,0 +1,4 @@
---
---
Test-only change (no package releases): regression coverage for `hook.resume()` from isolated route bundles, reproducing the o2flow `workflow@5.0.0-beta.26` incident ("Cannot find module as expression is too dynamic").
+9
View File
@@ -237,6 +237,15 @@ jobs:
env:
APP_NAME: "nextjs-turbopack"
# Regression test for the o2flow beta.26 incident: hook.resume() from a
# plain API route must resolve the workflow world inside an isolated
# (lambda-like) route bundle. Runs in this job because it also performs
# a production Turbopack build of the nextjs-turbopack workbench.
- name: Run Route Bundle Isolation Tests
run: pnpm vitest run packages/core/e2e/route-bundle-isolation.test.ts
env:
APP_NAME: "nextjs-turbopack"
vitest-plugin:
name: Vitest Plugin Tests
runs-on: ubuntu-latest
+55
View File
@@ -3226,6 +3226,61 @@ describe('e2e', () => {
});
});
// Regression test for the o2flow v5 upgrade incident (5.0.0-beta.26, fixed
// by #2752): a plain API route — no workflow directives anywhere in its
// module graph — importing a `defineHook()` hook from a shared module and
// calling `.resume()` on it. On broken versions the framework bundler
// tree-shook the world registration out of the route bundle and the resume
// failed with Turbopack's "Cannot find module as expression is too dynamic"
// stub before reaching any world API. Only deployed apps reproduce the
// broken case (isolated route bundles); local dev servers mask it because
// evaluating next.config registers the world process-wide — see
// route-bundle-isolation.test.ts for the locally-reproducible variant.
test.skipIf(!isNextJsApp)(
'plainModuleDoneHook resumed via plain API route (o2flow shape)',
{ timeout: 90_000 },
async () => {
const token = `plain-module-hook-${Math.random().toString(36).slice(2)}`;
const run = await start(
await getWorkflowMetadata(
deploymentUrl,
'workflows/102_plain_module_hook.ts',
'waitForPlainModuleHook'
),
[token]
);
await waitForHook(token, { runId: run.runId });
const res = await fetch(
new URL('/api/resume-plain-hook', deploymentUrl),
{
method: 'POST',
headers: {
'content-type': 'application/json',
...(await getTrustedSourcesHeaders()),
},
body: JSON.stringify({
token,
ok: true,
note: 'resumed-from-plain-route',
}),
}
);
const body = await res.text();
expect(res.status, `resume route responded ${res.status}: ${body}`).toBe(
200
);
const returnValue = await run.returnValue;
expect(returnValue).toEqual({
resumedWith: { ok: true, note: 'resumed-from-plain-route' },
plainModuleHookTestData: 'workflow_completed',
});
}
);
test(
'hookWithSleepWorkflow - hook payloads delivered correctly with concurrent sleep',
{ timeout: 90_000 },
@@ -0,0 +1,176 @@
import {
execFile as execFileOriginal,
exec as execOriginal,
} from 'child_process';
import path from 'path';
import { promisify } from 'util';
import { describe, expect, test } from 'vitest';
import { getWorkbenchAppPath } from './utils';
const exec = promisify(execOriginal);
const execFile = promisify(execFileOriginal);
/**
* Regression test for the o2flow v5 upgrade incident (workflow@5.0.0-beta.26,
* fixed by #2752 in 5.0.0-beta.28).
*
* # The bug
*
* A plain Next.js API route — no workflow directives anywhere in its module
* graph — imported a hook from a shared module and resumed it:
*
* ```ts
* // workflows/hooks.ts (no directives)
* export const sandboxDoneHook = defineHook<SandboxDoneEvent>();
*
* // app/api/internal/sandbox-complete/route.ts
* await sandboxDoneHook.resume(token, payload);
* ```
*
* `defineHook` comes from the root `workflow` entry, which at the time did
* NOT carry the `@workflow/core/runtime/world-init` side-effect import (only
* `workflow/api` did). Turbopack tree-shook `world.ts` — and its module-load
* `globalThis[GetWorldFnKey] ??= getWorld` registration — out of the route
* bundle. `getWorldLazy()` then fell through to its last-resort
* `await import(['./world', 'js'].join('.'))`, which Turbopack compiles into
* a stub that throws:
*
* Cannot find module as expression is too dynamic
*
* so every `hook.resume()` from that route failed and workflow runs hung on
* their hooks forever.
*
* # Why nothing caught it before production
*
* The failure only manifests when the route bundle is loaded in ISOLATION,
* like a Vercel lambda (where next.config.js is serialized at build time and
* never evaluated at runtime). Under local `next dev` / `next start`, loading
* `next.config.ts` evaluates the `workflow/next` module chain in the same
* process, which registers the world on `globalThis` and masks the bug. The
* e2e suites all drive a long-lived server process, so they were masked too.
*
* # What this test does
*
* 1. Builds the nextjs-turbopack workbench with a production Turbopack build
* (the local world target — the world choice is irrelevant to the bug).
* 2. Loads ONLY the compiled route bundle for `/api/resume-plain-hook` in a
* bare Node.js subprocess — simulating a cold Vercel lambda — and invokes
* its POST handler with a token that doesn't exist.
* 3. Expects the well-formed `Hook not found` failure, proving the route
* resolved the workflow world from inside an isolated bundle. On broken
* versions this instead reports the Turbopack dynamic-require stub error.
*/
describe('route bundle isolation (o2flow hook.resume regression)', () => {
test(
'defineHook().resume() resolves the world inside an isolated route bundle',
{ timeout: 300_000 },
async () => {
const appPath = getWorkbenchAppPath('nextjs-turbopack');
// Strip Vercel env so the build deterministically injects the local
// world target regardless of where this test runs.
const buildEnv = { ...process.env, FORCE_COLOR: '0' };
delete buildEnv.VERCEL;
delete buildEnv.VERCEL_ENV;
delete buildEnv.VERCEL_DEPLOYMENT_ID;
delete buildEnv.VERCEL_PROJECT_ID;
await exec('pnpm build', { cwd: appPath, env: buildEnv });
const routeBundlePath = path.join(
appPath,
'.next/server/app/api/resume-plain-hook/route.js'
);
// Load the route bundle and call its handler in a fresh subprocess so
// nothing else (dev server, next.config evaluation, other routes) can
// register the workflow world on globalThis first. This mirrors how a
// Vercel lambda cold-starts an isolated route function.
//
// The harness prefixes its single result line with a unique marker so
// the test can find it even when the route bundle or the world logs to
// stdout before or after it.
const RESULT_MARKER = '__ROUTE_BUNDLE_ISOLATION_RESULT__';
const harness = `
const m = require(process.argv[1]);
const report = (result) =>
console.log(${JSON.stringify(RESULT_MARKER)} + JSON.stringify(result));
// module.exports may resolve asynchronously (Turbopack async modules)
Promise.resolve(m)
.then(async (mod) => {
const POST = mod.routeModule?.userland?.POST;
if (typeof POST !== 'function') {
report({
harnessError: 'route bundle did not expose routeModule.userland.POST',
exportKeys: Object.keys(mod),
});
return;
}
const res = await POST(
new Request('http://localhost/api/resume-plain-hook', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
token: 'route-bundle-isolation-nonexistent-token',
ok: true,
}),
})
);
report({ status: res.status, body: await res.text() });
})
.catch((err) => {
report({
harnessError: err instanceof Error ? err.message : String(err),
});
});
`;
const { stdout } = await execFile(
process.execPath,
['-e', harness, routeBundlePath],
{ cwd: appPath, env: buildEnv, timeout: 60_000 }
);
const resultLine = stdout
.split('\n')
.filter((line) => line.includes(RESULT_MARKER))
.at(-1);
if (!resultLine) {
throw new Error(
`route bundle harness produced no ${RESULT_MARKER} line; full stdout:\n${stdout}`
);
}
const result = JSON.parse(
resultLine.slice(
resultLine.indexOf(RESULT_MARKER) + RESULT_MARKER.length
)
) as {
status?: number;
body?: string;
harnessError?: string;
exportKeys?: string[];
};
expect(
result.harnessError,
`harness failed: ${resultLine}\nfull stdout:\n${stdout}`
).toBeUndefined();
// The exact failure from the o2flow incident: Turbopack replaced the
// world-resolution fallback with a dynamic-require stub because the
// world registration was tree-shaken out of the route bundle.
expect(result.body).not.toContain(
'Cannot find module as expression is too dynamic'
);
// The fixed code's loud failure for the same class of bug (world-init
// side effect missing from the bundle). Seeing this means the
// registration chain from the root `workflow` entry broke again.
expect(result.body).not.toContain('world runtime was not initialized');
// The healthy outcome for a token that doesn't exist: the world
// resolved inside the isolated bundle and the lookup failed cleanly.
expect(result.status).toBe(500);
expect(result.body).toContain('Hook not found');
}
);
});
@@ -0,0 +1,20 @@
import { plainModuleDoneHook } from './_plain_module_hooks';
/**
* Workflow half of the o2flow-shaped hook reproduction (see
* `_plain_module_hooks.ts`): create a hook — defined via `defineHook()` in a
* plain shared module — with a caller-provided token, then suspend until an
* API route resumes it via `plainModuleDoneHook.resume(token, payload)`.
*/
export async function waitForPlainModuleHook(token: string) {
'use workflow';
using hook = plainModuleDoneHook.create({ token });
const payload = await hook;
return {
resumedWith: payload,
plainModuleHookTestData: 'workflow_completed',
};
}
@@ -0,0 +1,29 @@
import { defineHook } from 'workflow';
/**
* Mirrors vercel/o2flow's `workflows/hooks.ts`: a typed hook defined in a
* plain shared module with NO workflow/step directives. Do not add the
* literal directive strings anywhere in this file (not even in comments):
* the Next.js integration's Turbopack rule matches file CONTENT for them,
* and this module must stay outside the workflow loader to faithfully
* reproduce the o2flow setup.
*
* The module is imported from two very different bundles:
* 1. A workflow file (see `102_plain_module_hook.ts`), which calls
* `.create({ token })` inside the workflow compiled by the SWC plugin.
* 2. A plain framework API route (e.g. `app/api/resume-plain-hook/route.ts`
* in the Next.js workbenches), which calls `.resume(token, payload)`
* bundled by the framework's own bundler (Turbopack/webpack/etc.) with
* no workflow directives anywhere in the route's module graph.
*
* The second path is the o2flow "sandbox-complete" callback shape that broke
* with "Cannot find module as expression is too dynamic" on workflow
* 5.0.0-beta.26 under Turbopack (fixed by #2752 in beta.28). See
* packages/core/e2e/route-bundle-isolation.test.ts for the full story.
*/
export interface PlainModuleDoneEvent {
ok: boolean;
note?: string;
}
export const plainModuleDoneHook = defineHook<PlainModuleDoneEvent>();
@@ -0,0 +1,30 @@
import { NextResponse } from 'next/server';
import { plainModuleDoneHook } from '@/workflows/_plain_module_hooks';
/**
* Mirrors vercel/o2flow's `app/api/internal/sandbox-complete/route.ts`: a
* plain API route (no workflow directives anywhere in its module graph) that
* resumes a workflow hook defined via `defineHook()` in a shared module.
*
* This exercises the host-bundle `hook.resume()` path through the
* framework's own bundler, which is not covered by the e2e tests that call
* `resumeHook()` from the (unbundled) test process.
*/
export async function POST(req: Request) {
const { token, ok, note } = (await req.json()) as {
token: string;
ok: boolean;
note?: string;
};
try {
await plainModuleDoneHook.resume(token, { ok, note });
} catch (err) {
return NextResponse.json(
{ error: err instanceof Error ? err.message : String(err) },
{ status: 500 }
);
}
return NextResponse.json({ ok: true });
}
@@ -0,0 +1 @@
../../example/workflows/102_plain_module_hook.ts
@@ -0,0 +1 @@
../../example/workflows/_plain_module_hooks.ts
@@ -0,0 +1,30 @@
import { NextResponse } from 'next/server';
import { plainModuleDoneHook } from '@/workflows/_plain_module_hooks';
/**
* Mirrors vercel/o2flow's `app/api/internal/sandbox-complete/route.ts`: a
* plain API route (no workflow directives anywhere in its module graph) that
* resumes a workflow hook defined via `defineHook()` in a shared module.
*
* This exercises the host-bundle `hook.resume()` path through the
* framework's own bundler, which is not covered by the e2e tests that call
* `resumeHook()` from the (unbundled) test process.
*/
export async function POST(req: Request) {
const { token, ok, note } = (await req.json()) as {
token: string;
ok: boolean;
note?: string;
};
try {
await plainModuleDoneHook.resume(token, { ok, note });
} catch (err) {
return NextResponse.json(
{ error: err instanceof Error ? err.message : String(err) },
{ status: 500 }
);
}
return NextResponse.json({ ok: true });
}
@@ -0,0 +1 @@
../../example/workflows/102_plain_module_hook.ts
@@ -0,0 +1 @@
../../example/workflows/_plain_module_hooks.ts