mirror of
https://github.com/vercel/workflow.git
synced 2026-09-14 19:59:43 +08:00
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:
@@ -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").
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user