mirror of
https://github.com/vercel/workflow.git
synced 2026-09-14 19:59:43 +08:00
1550 lines
52 KiB
TypeScript
1550 lines
52 KiB
TypeScript
import fs from 'node:fs/promises';
|
||
import path from 'node:path';
|
||
import { setTimeout as sleep } from 'node:timers/promises';
|
||
import { afterEach, assert, beforeAll, describe, expect, test } from 'vitest';
|
||
import { start } from '../src/runtime';
|
||
import { getWorkbenchAppPath, getWorkflowMetadata, setupWorld } from './utils';
|
||
|
||
export interface DevTestConfig {
|
||
generatedStepRegistrationPath: string;
|
||
generatedWorkflowPath: string;
|
||
apiFilePath: string;
|
||
apiFileImportPath: string;
|
||
canary?: boolean;
|
||
/** The workflow file to modify for testing HMR. Defaults to '3_streams.ts' */
|
||
testWorkflowFile?: string;
|
||
/** The workflows directory relative to appPath. Defaults to 'workflows' */
|
||
workflowsDir?: string;
|
||
}
|
||
|
||
const SOURCE_MAP_WARNING = 'failed to read input source map';
|
||
const SOURCE_MAP_FIXTURE_PACKAGE = 'workflow-sourcemap-warning-fixture';
|
||
const SOURCE_MAP_COMMENT = '//# sourceMapping' + 'URL=index.js.map';
|
||
|
||
function getConfigFromEnv(): DevTestConfig | null {
|
||
const envConfig = process.env.DEV_TEST_CONFIG;
|
||
if (envConfig) {
|
||
try {
|
||
return JSON.parse(envConfig);
|
||
} catch (e) {
|
||
console.error('Failed to parse DEV_TEST_CONFIG:', e);
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
|
||
export function createDevTests(config?: DevTestConfig) {
|
||
const finalConfig = config || getConfigFromEnv();
|
||
if (!finalConfig) {
|
||
throw new Error(
|
||
'No dev test config provided via parameter or DEV_TEST_CONFIG env var'
|
||
);
|
||
}
|
||
describe('dev e2e', () => {
|
||
// Each prewarm/trigger fetch is hard-bounded by this so cleanup never hangs
|
||
// on a wedged dev server.
|
||
const PREWARM_FETCH_TIMEOUT_MS = 5_000;
|
||
// The afterEach cleanup can issue three *sequential* prewarms (before the
|
||
// delete of an added file, after it, and after the directory removals)
|
||
// while the dev server is mid-rebuild — the teardown of a test that added a
|
||
// workflow file and edited an import is exactly when both rebuild and
|
||
// respond slowly. Its budget must therefore exceed 3× PREWARM_FETCH_TIMEOUT_MS
|
||
// (plus file IO) with headroom, or it trips vitest's 10s default hook
|
||
// timeout. The bounded fetches mean this can't hang indefinitely, so a
|
||
// generous budget is safe.
|
||
//
|
||
// Cleanup also waits for the generated step registrations to drop every
|
||
// file it deleted, so its budget has to cover that too.
|
||
// A delete converges in one watcher event plus one rediscovery — ~2s on the
|
||
// macOS baseline. A watcher that dropped the unlink never converges, so a
|
||
// large budget only delays the failure.
|
||
const STEP_REGISTRATION_CONVERGENCE_TIMEOUT_MS =
|
||
process.platform === 'win32' ? 60_000 : 20_000;
|
||
const CLEANUP_HOOK_TIMEOUT_MS =
|
||
PREWARM_FETCH_TIMEOUT_MS * 4 + STEP_REGISTRATION_CONVERGENCE_TIMEOUT_MS;
|
||
const appPath = getWorkbenchAppPath();
|
||
const deploymentUrl = process.env.DEPLOYMENT_URL;
|
||
const generatedStepRegistration = path.join(
|
||
appPath,
|
||
finalConfig.generatedStepRegistrationPath
|
||
);
|
||
const generatedWorkflow = path.join(
|
||
appPath,
|
||
finalConfig.generatedWorkflowPath
|
||
);
|
||
const testWorkflowFile = finalConfig.testWorkflowFile ?? '3_streams.ts';
|
||
const workflowsDir = finalConfig.workflowsDir ?? 'workflows';
|
||
const usesNextFlowRoute = generatedWorkflow.includes(
|
||
path.join('app', '.well-known', 'workflow', 'v1', 'flow', 'route.js')
|
||
);
|
||
const shouldRunNextFlowRouteHmrTests =
|
||
usesNextFlowRoute && process.platform !== 'win32';
|
||
const workflowManifestPath = path.join(
|
||
appPath,
|
||
'app/.well-known/workflow/v1/manifest.json'
|
||
);
|
||
// Next canary and Windows can queue Workflow rediscovery behind route
|
||
// compilation long enough that the default budget races test cleanup.
|
||
const hmrRediscoveryTimeoutMs = finalConfig.canary
|
||
? 180_000
|
||
: process.platform === 'win32'
|
||
? 120_000
|
||
: 50_000;
|
||
const hmrTestTimeoutMs = finalConfig.canary
|
||
? 210_000
|
||
: process.platform === 'win32'
|
||
? 140_000
|
||
: 70_000;
|
||
const multiPhaseHmrTestTimeoutMs =
|
||
hmrTestTimeoutMs + hmrRediscoveryTimeoutMs;
|
||
const flowRouteHmrRediscoveryTimeoutMs = finalConfig.canary
|
||
? process.env.APP_NAME === 'nextjs-webpack'
|
||
? 300_000
|
||
: 240_000
|
||
: hmrRediscoveryTimeoutMs;
|
||
const flowRouteHmrFuzzTimeoutMs = finalConfig.canary ? 480_000 : 240_000;
|
||
const readManifestStepFunctionNames = async (): Promise<string[]> => {
|
||
const manifestJson = await fs.readFile(workflowManifestPath, 'utf8');
|
||
const manifest = JSON.parse(manifestJson) as {
|
||
steps?: Record<string, Record<string, unknown>>;
|
||
};
|
||
return Object.values(manifest.steps || {}).flatMap((entry) =>
|
||
Object.keys(entry)
|
||
);
|
||
};
|
||
const readManifestWorkflowFunctionNames = async (): Promise<string[]> => {
|
||
const manifestJson = await fs.readFile(workflowManifestPath, 'utf8');
|
||
const manifest = JSON.parse(manifestJson) as {
|
||
workflows?: Record<string, Record<string, unknown>>;
|
||
};
|
||
return Object.values(manifest.workflows || {}).flatMap((entry) =>
|
||
Object.keys(entry)
|
||
);
|
||
};
|
||
const readGeneratedArtifactSnapshot = async () => ({
|
||
stepMtimeMs: (await fs.stat(generatedStepRegistration)).mtimeMs,
|
||
workflowMtimeMs: (await fs.stat(generatedWorkflow)).mtimeMs,
|
||
manifestMtimeMs: usesNextFlowRoute
|
||
? (await fs.stat(workflowManifestPath)).mtimeMs
|
||
: undefined,
|
||
});
|
||
const readFileIfExists = async (
|
||
filePath: string
|
||
): Promise<string | null> => {
|
||
try {
|
||
return await fs.readFile(filePath, 'utf8');
|
||
} catch (error) {
|
||
if (
|
||
error &&
|
||
typeof error === 'object' &&
|
||
'code' in error &&
|
||
error.code === 'ENOENT'
|
||
) {
|
||
return null;
|
||
}
|
||
throw error;
|
||
}
|
||
};
|
||
const readGeneratedWorkflowOutput = async (): Promise<string> => {
|
||
const outputs = [await readFileIfExists(generatedWorkflow)].filter(
|
||
(output): output is string => output !== null
|
||
);
|
||
|
||
if (outputs.length === 0) {
|
||
throw new Error('Generated workflow outputs were not found');
|
||
}
|
||
|
||
return outputs.join('\n');
|
||
};
|
||
const restoreFiles: Array<{ path: string; content: string }> = [];
|
||
const restoreDirectories: string[] = [];
|
||
/**
|
||
* The generated step registrations import every discovered step file by
|
||
* path, so deleting a file that declares a step only stops breaking the
|
||
* flow route once a rediscovery has regenerated them. A watcher that drops
|
||
* the unlink therefore leaves the generated file importing a path that no
|
||
* longer exists: the flow route stops compiling and every later workflow
|
||
* dispatch in the job gets a 500 that points at the fixture rather than at
|
||
* whatever test is running. The generated workflow bundle inlines workflow
|
||
* sources instead of importing them, so only step files can strand it.
|
||
*/
|
||
const appRelativePosixPath = (filePath: string) =>
|
||
path.relative(appPath, filePath).split(path.sep).join('/');
|
||
const findStrandedStepRegistrations = async (deletedPaths: string[]) => {
|
||
const registrations =
|
||
(await readFileIfExists(generatedStepRegistration)) ?? '';
|
||
return deletedPaths.filter((filePath) =>
|
||
registrations.includes(appRelativePosixPath(filePath))
|
||
);
|
||
};
|
||
/**
|
||
* Deleted files whose contents are still importable are harmless, so on a
|
||
* convergence failure the fixture is written back: the shared dev server
|
||
* keeps serving the rest of the suite instead of 500ing on every request,
|
||
* and the failure surfaces here, where it is diagnosable.
|
||
*/
|
||
const waitForDeletedFilesToLeaveStepRegistrations = async (
|
||
deleted: Array<{ path: string; content: string }>
|
||
) => {
|
||
if (deleted.length === 0) {
|
||
return;
|
||
}
|
||
const deletedPaths = deleted.map((item) => item.path);
|
||
try {
|
||
await pollUntil({
|
||
description:
|
||
'generated step registrations to drop the deleted workflow files',
|
||
timeoutMs: STEP_REGISTRATION_CONVERGENCE_TIMEOUT_MS,
|
||
intervalMs: 250,
|
||
check: async () => {
|
||
expect(await findStrandedStepRegistrations(deletedPaths)).toEqual(
|
||
[]
|
||
);
|
||
},
|
||
});
|
||
} catch {
|
||
const stranded = await findStrandedStepRegistrations(deletedPaths);
|
||
await Promise.all(
|
||
deleted
|
||
.filter((item) => stranded.includes(item.path))
|
||
.map((item) => fs.writeFile(item.path, item.content))
|
||
);
|
||
throw new Error(
|
||
`Deleted workflow files are still imported by ${finalConfig.generatedStepRegistrationPath} ` +
|
||
`after ${STEP_REGISTRATION_CONVERGENCE_TIMEOUT_MS}ms: ${stranded
|
||
.map(appRelativePosixPath)
|
||
.join(', ')}. The dev server missed the deletion, so the flow ` +
|
||
'route would 500 for every later request. The files have been ' +
|
||
'restored to keep the dev server usable.'
|
||
);
|
||
}
|
||
};
|
||
const devServerLogPath = process.env.DEV_SERVER_LOG_PATH;
|
||
const shouldAssertDevHmrLogs = process.env.WORKFLOW_DEV_HMR_LOGS === '1';
|
||
const hmrLogMessages = {
|
||
skip: 'workflow dev hmr: skip',
|
||
hot: 'workflow dev hmr: hot rebuild',
|
||
full: 'workflow dev hmr: full rediscovery',
|
||
};
|
||
const hmrRebuildCompleteMessage = 'workflow dev hmr: rebuild complete';
|
||
|
||
const fetchWithTimeout = (pathname: string) => {
|
||
if (!deploymentUrl) {
|
||
return Promise.resolve();
|
||
}
|
||
|
||
return fetch(new URL(pathname, deploymentUrl), {
|
||
signal: AbortSignal.timeout(PREWARM_FETCH_TIMEOUT_MS),
|
||
});
|
||
};
|
||
|
||
const prewarm = async () => {
|
||
// Pre-warm the app with bounded requests so cleanup hooks cannot hang.
|
||
await Promise.all([
|
||
fetchWithTimeout('/').catch(() => {}),
|
||
fetchWithTimeout('/api/chat').catch(() => {}),
|
||
]);
|
||
};
|
||
const decodeDevServerLog = (content: Buffer) => {
|
||
if (content.length >= 2 && content[0] === 0xff && content[1] === 0xfe) {
|
||
return content.toString('utf16le');
|
||
}
|
||
|
||
const sample = content.subarray(0, Math.min(content.length, 200));
|
||
const nullByteCount = sample.filter((byte) => byte === 0).length;
|
||
return nullByteCount > sample.length / 4
|
||
? content.toString('utf16le')
|
||
: content.toString('utf8');
|
||
};
|
||
const readDevServerLog = async (): Promise<string> => {
|
||
if (!devServerLogPath) {
|
||
return '';
|
||
}
|
||
return await fs
|
||
.readFile(devServerLogPath)
|
||
.then(decodeDevServerLog)
|
||
.catch(() => '');
|
||
};
|
||
/**
|
||
* Wait until the dev server's HMR pipeline is quiescent: every rebuild
|
||
* the log says started (`hot rebuild` / `full rediscovery`) has logged
|
||
* `rebuild complete`, and no new HMR line has appeared for a short
|
||
* window (covering watcher latency for a just-landed write plus the
|
||
* flush debounce).
|
||
*
|
||
* Rebuilds are serialized and can take multi-second on CI, so a write
|
||
* from a previous case (or a teardown restore) can still be rebuilding
|
||
* — or sitting in the queue — when the next exact-count window would
|
||
* open. Draining here keeps those legitimate rebuild lines out of the
|
||
* next window instead of failing it with over-counts.
|
||
*/
|
||
const hmrQuiescenceQuietMs = 2_000;
|
||
const waitForHmrQuiescence = async () => {
|
||
if (!devServerLogPath || !shouldAssertDevHmrLogs) {
|
||
return;
|
||
}
|
||
let lastCounts = '';
|
||
let quietSince = Date.now();
|
||
await pollUntil({
|
||
description: 'dev server HMR pipeline to go quiescent',
|
||
timeoutMs: hmrRediscoveryTimeoutMs,
|
||
intervalMs: 250,
|
||
check: async () => {
|
||
const log = await readDevServerLog();
|
||
const hot = countLogMessage(log, hmrLogMessages.hot);
|
||
const full = countLogMessage(log, hmrLogMessages.full);
|
||
const skip = countLogMessage(log, hmrLogMessages.skip);
|
||
const complete = countLogMessage(log, hmrRebuildCompleteMessage);
|
||
const counts = `${hot}/${full}/${skip}/${complete}`;
|
||
if (counts !== lastCounts) {
|
||
lastCounts = counts;
|
||
quietSince = Date.now();
|
||
}
|
||
expect(complete).toBeGreaterThanOrEqual(hot + full);
|
||
expect(Date.now() - quietSince).toBeGreaterThanOrEqual(
|
||
hmrQuiescenceQuietMs
|
||
);
|
||
},
|
||
});
|
||
};
|
||
|
||
// Cursors open exact-count windows, so they only get taken once the
|
||
// pipeline is drained — every call site writes after taking its cursor.
|
||
const readDevServerLogCursor = async () => {
|
||
if (!devServerLogPath || !shouldAssertDevHmrLogs) {
|
||
return undefined;
|
||
}
|
||
await waitForHmrQuiescence();
|
||
return (await readDevServerLog()).length;
|
||
};
|
||
const countLogMessage = (log: string, message: string) =>
|
||
log.split(message).length - 1;
|
||
type ExpectedHmrLogCount = number | { min?: number; max?: number };
|
||
const expectLogCount = (
|
||
actual: number,
|
||
expected: ExpectedHmrLogCount | undefined
|
||
) => {
|
||
if (typeof expected === 'number') {
|
||
// Canary webpack can emit duplicate watcher events for one edit; keep
|
||
// stable exact while treating canary counts as lower bounds.
|
||
if (finalConfig.canary) {
|
||
expect(actual).toBeGreaterThanOrEqual(expected);
|
||
return;
|
||
}
|
||
expect(actual).toBe(expected);
|
||
return;
|
||
}
|
||
expect(actual).toBeGreaterThanOrEqual(expected?.min ?? 0);
|
||
if (expected?.max !== undefined) {
|
||
expect(actual).toBeLessThanOrEqual(expected.max);
|
||
}
|
||
};
|
||
const expectHmrLogCounts = async (
|
||
cursor: number | undefined,
|
||
expected: {
|
||
skip?: ExpectedHmrLogCount;
|
||
hot?: ExpectedHmrLogCount;
|
||
full?: ExpectedHmrLogCount;
|
||
}
|
||
) => {
|
||
if (cursor === undefined) {
|
||
return;
|
||
}
|
||
await pollUntil({
|
||
description: 'dev server HMR logs to match expected rebuild counts',
|
||
timeoutMs: hmrRediscoveryTimeoutMs,
|
||
intervalMs: 250,
|
||
check: async () => {
|
||
const log = (await readDevServerLog()).slice(cursor);
|
||
expectLogCount(
|
||
countLogMessage(log, hmrLogMessages.skip),
|
||
expected.skip
|
||
);
|
||
expectLogCount(
|
||
countLogMessage(log, hmrLogMessages.hot),
|
||
expected.hot
|
||
);
|
||
expectLogCount(
|
||
countLogMessage(log, hmrLogMessages.full),
|
||
expected.full
|
||
);
|
||
},
|
||
});
|
||
};
|
||
|
||
const pollUntil = async ({
|
||
description,
|
||
check,
|
||
timeoutMs = 25_000,
|
||
intervalMs = 1_000,
|
||
}: {
|
||
description: string;
|
||
check: () => Promise<void>;
|
||
timeoutMs?: number;
|
||
intervalMs?: number;
|
||
}) => {
|
||
const deadline = Date.now() + timeoutMs;
|
||
let lastError: unknown = null;
|
||
|
||
while (Date.now() < deadline) {
|
||
try {
|
||
await check();
|
||
return;
|
||
} catch (error) {
|
||
lastError = error;
|
||
await new Promise((res) => setTimeout(res, intervalMs));
|
||
}
|
||
}
|
||
|
||
const lastErrorSuffix =
|
||
lastError instanceof Error
|
||
? ` Last error: ${lastError.message}`
|
||
: lastError
|
||
? ` Last error: ${String(lastError)}`
|
||
: '';
|
||
throw new Error(
|
||
`Timed out after ${timeoutMs}ms waiting for ${description}.${lastErrorSuffix}`
|
||
);
|
||
};
|
||
const waitForHmrReady = async () => {
|
||
if (!devServerLogPath || !shouldAssertDevHmrLogs) {
|
||
return;
|
||
}
|
||
await pollUntil({
|
||
description: 'dev server HMR watcher to be ready',
|
||
timeoutMs: 50_000,
|
||
intervalMs: 250,
|
||
check: async () => {
|
||
expect(await readDevServerLog()).toContain('workflow dev hmr: ready');
|
||
},
|
||
});
|
||
};
|
||
const waitForGeneratedArtifactStability = async () => {
|
||
await prewarm();
|
||
let previous = await readGeneratedArtifactSnapshot();
|
||
for (let i = 0; i < 5; i++) {
|
||
await sleep(1_000);
|
||
const next = await readGeneratedArtifactSnapshot();
|
||
if (
|
||
previous.stepMtimeMs === next.stepMtimeMs &&
|
||
previous.workflowMtimeMs === next.workflowMtimeMs
|
||
) {
|
||
return next;
|
||
}
|
||
previous = next;
|
||
}
|
||
return previous;
|
||
};
|
||
const expectGeneratedArtifactsUnchanged = async (
|
||
before: Awaited<ReturnType<typeof readGeneratedArtifactSnapshot>>
|
||
) => {
|
||
await prewarm();
|
||
await sleep(3_000);
|
||
const after = await readGeneratedArtifactSnapshot();
|
||
expect(after.stepMtimeMs).toBe(before.stepMtimeMs);
|
||
expect(after.workflowMtimeMs).toBe(before.workflowMtimeMs);
|
||
return after;
|
||
};
|
||
|
||
beforeAll(async () => {
|
||
await prewarm();
|
||
}, CLEANUP_HOOK_TIMEOUT_MS);
|
||
|
||
afterEach(async () => {
|
||
// Restore file contents before deleting any files. If a deletion races
|
||
// ahead of an api-file restore, the dev server briefly sees an import
|
||
// pointing at a missing module and fails compilation. On Windows that
|
||
// failure can stick in Turbopack's generated workflow outputs, and every
|
||
// subsequent step request returns 500.
|
||
const toRestore = restoreFiles.filter((item) => item.content !== '');
|
||
const toDelete = restoreFiles.filter((item) => item.content === '');
|
||
// Captured before the delete so a file the dev server failed to forget
|
||
// can be put back verbatim. See
|
||
// `waitForDeletedFilesToLeaveStepRegistrations`.
|
||
const deleted = await Promise.all(
|
||
toDelete.map(async (item) => ({
|
||
path: item.path,
|
||
content: (await readFileIfExists(item.path)) ?? '',
|
||
}))
|
||
);
|
||
try {
|
||
await Promise.all(
|
||
toRestore.map((item) => fs.writeFile(item.path, item.content))
|
||
);
|
||
if (toDelete.length > 0) {
|
||
await prewarm();
|
||
}
|
||
await Promise.all(
|
||
toDelete.map((item) => fs.rm(item.path, { force: true }))
|
||
);
|
||
await prewarm();
|
||
// Runs before the directory removals below so a restored fixture still
|
||
// finds the node_modules package it imports.
|
||
await waitForDeletedFilesToLeaveStepRegistrations(deleted);
|
||
await Promise.all(
|
||
restoreDirectories.map((dir) =>
|
||
fs.rm(dir, { recursive: true, force: true })
|
||
)
|
||
);
|
||
await prewarm();
|
||
} finally {
|
||
restoreFiles.length = 0;
|
||
restoreDirectories.length = 0;
|
||
}
|
||
}, CLEANUP_HOOK_TIMEOUT_MS);
|
||
|
||
test.runIf(shouldRunNextFlowRouteHmrTests)(
|
||
'should not rebuild workflows on Next page body-only change',
|
||
{ timeout: hmrTestTimeoutMs },
|
||
async () => {
|
||
await waitForHmrReady();
|
||
|
||
const pageFile = path.join(appPath, 'app/page.tsx');
|
||
const pageContent = await fs.readFile(pageFile, 'utf8');
|
||
restoreFiles.push({ path: pageFile, content: pageContent });
|
||
|
||
const snapshot = await waitForGeneratedArtifactStability();
|
||
const logCursor = await readDevServerLogCursor();
|
||
await fs.writeFile(
|
||
pageFile,
|
||
`${pageContent}
|
||
// workflow hmr body-only probe
|
||
`
|
||
);
|
||
|
||
await expectGeneratedArtifactsUnchanged(snapshot);
|
||
await expectHmrLogCounts(logCursor, { skip: 1 });
|
||
}
|
||
);
|
||
|
||
test.runIf(shouldRunNextFlowRouteHmrTests)(
|
||
'should rediscover workflows on Next page directive change',
|
||
{ timeout: hmrTestTimeoutMs },
|
||
async () => {
|
||
await waitForHmrReady();
|
||
|
||
const pageFile = path.join(appPath, 'app/page.tsx');
|
||
const pageContent = await fs.readFile(pageFile, 'utf8');
|
||
restoreFiles.push({ path: pageFile, content: pageContent });
|
||
|
||
const logCursor = await readDevServerLogCursor();
|
||
await fs.writeFile(
|
||
pageFile,
|
||
`${pageContent}
|
||
|
||
export async function hmrPageWorkflow() {
|
||
'use workflow';
|
||
return 'hmr page workflow';
|
||
}
|
||
`
|
||
);
|
||
|
||
await pollUntil({
|
||
description: 'page-defined workflow to appear in manifest',
|
||
timeoutMs: hmrRediscoveryTimeoutMs,
|
||
intervalMs: 500,
|
||
check: async () => {
|
||
await prewarm();
|
||
expect(await readManifestWorkflowFunctionNames()).toContain(
|
||
'hmrPageWorkflow'
|
||
);
|
||
},
|
||
});
|
||
await expectHmrLogCounts(logCursor, { full: 1, skip: { max: 1 } });
|
||
}
|
||
);
|
||
|
||
test.runIf(
|
||
shouldRunNextFlowRouteHmrTests &&
|
||
process.env.APP_NAME === 'nextjs-turbopack'
|
||
)(
|
||
'should rediscover workflows when a registry import changes',
|
||
{ timeout: 70_000 },
|
||
async () => {
|
||
await waitForHmrReady();
|
||
|
||
const registryFile = path.join(appPath, '_workflows.ts');
|
||
const registryFileContent = await fs.readFile(registryFile, 'utf8');
|
||
restoreFiles.push({
|
||
path: registryFile,
|
||
content: registryFileContent,
|
||
});
|
||
|
||
const registryWithoutSimpleImport = registryFileContent
|
||
.replace(
|
||
/^import \* as workflow_1_simple from '\.\/workflows\/1_simple';$/m,
|
||
"// import * as workflow_1_simple from './workflows/1_simple';"
|
||
)
|
||
.replace(
|
||
/^ {2}'workflows\/1_simple\.ts': workflow_1_simple,$/m,
|
||
" // 'workflows/1_simple.ts': workflow_1_simple,"
|
||
);
|
||
expect(registryWithoutSimpleImport).not.toBe(registryFileContent);
|
||
expect(registryWithoutSimpleImport).toContain(
|
||
"// import * as workflow_1_simple from './workflows/1_simple';"
|
||
);
|
||
expect(registryWithoutSimpleImport).toContain(
|
||
"// 'workflows/1_simple.ts': workflow_1_simple,"
|
||
);
|
||
|
||
await fs.writeFile(registryFile, registryWithoutSimpleImport);
|
||
await pollUntil({
|
||
description: 'registry import rediscovery to keep manifest readable',
|
||
timeoutMs: 50_000,
|
||
intervalMs: 500,
|
||
check: async () => {
|
||
await prewarm();
|
||
expect(await readManifestWorkflowFunctionNames()).toContain(
|
||
'simple'
|
||
);
|
||
},
|
||
});
|
||
}
|
||
);
|
||
|
||
test(
|
||
'should rebuild on workflow change',
|
||
{
|
||
timeout: usesNextFlowRoute
|
||
? multiPhaseHmrTestTimeoutMs
|
||
: hmrTestTimeoutMs,
|
||
},
|
||
async () => {
|
||
if (usesNextFlowRoute) {
|
||
await waitForHmrReady();
|
||
}
|
||
|
||
let workflowFile = path.join(appPath, workflowsDir, testWorkflowFile);
|
||
let content = await fs.readFile(workflowFile, 'utf8');
|
||
|
||
if (usesNextFlowRoute) {
|
||
workflowFile = path.join(
|
||
appPath,
|
||
workflowsDir,
|
||
'dev-test-workflow-change.ts'
|
||
);
|
||
const apiFile = path.join(appPath, finalConfig.apiFilePath);
|
||
const apiFileContent = await fs.readFile(apiFile, 'utf8');
|
||
restoreFiles.push({ path: apiFile, content: apiFileContent });
|
||
restoreFiles.push({ path: workflowFile, content: '' });
|
||
|
||
content = `export async function devTestWorkflowChangeBase() {
|
||
'use workflow';
|
||
return 'base';
|
||
}
|
||
`;
|
||
await fs.writeFile(workflowFile, content);
|
||
await fs.writeFile(
|
||
apiFile,
|
||
`import '${finalConfig.apiFileImportPath}/${workflowsDir}/dev-test-workflow-change';
|
||
${apiFileContent}`
|
||
);
|
||
await pollUntil({
|
||
description: 'workflow-change fixture to appear in manifest',
|
||
timeoutMs: hmrRediscoveryTimeoutMs,
|
||
check: async () => {
|
||
await prewarm();
|
||
expect(await readManifestWorkflowFunctionNames()).toContain(
|
||
'devTestWorkflowChangeBase'
|
||
);
|
||
},
|
||
});
|
||
}
|
||
|
||
await fs.writeFile(
|
||
workflowFile,
|
||
`${content}
|
||
|
||
export async function myNewWorkflow() {
|
||
'use workflow'
|
||
return 'hello world'
|
||
}
|
||
`
|
||
);
|
||
if (!usesNextFlowRoute) {
|
||
restoreFiles.push({ path: workflowFile, content });
|
||
}
|
||
|
||
await pollUntil({
|
||
description: 'generated workflow to include myNewWorkflow',
|
||
timeoutMs: usesNextFlowRoute ? hmrRediscoveryTimeoutMs : 25_000,
|
||
check: async () => {
|
||
if (usesNextFlowRoute) {
|
||
await prewarm();
|
||
const manifestFunctionNames =
|
||
await readManifestWorkflowFunctionNames();
|
||
expect(manifestFunctionNames).toContain('myNewWorkflow');
|
||
return;
|
||
}
|
||
|
||
const workflowContent = await readGeneratedWorkflowOutput();
|
||
expect(workflowContent).toContain('myNewWorkflow');
|
||
},
|
||
});
|
||
}
|
||
);
|
||
|
||
test.runIf(!usesNextFlowRoute)(
|
||
'should rebuild on step change',
|
||
{ timeout: 70_000 },
|
||
async () => {
|
||
if (usesNextFlowRoute) {
|
||
await waitForHmrReady();
|
||
}
|
||
|
||
let stepFile = path.join(appPath, workflowsDir, testWorkflowFile);
|
||
let content = await fs.readFile(stepFile, 'utf8');
|
||
|
||
if (usesNextFlowRoute) {
|
||
stepFile = path.join(
|
||
appPath,
|
||
workflowsDir,
|
||
'dev-test-step-change.ts'
|
||
);
|
||
const apiFile = path.join(appPath, finalConfig.apiFilePath);
|
||
const apiFileContent = await fs.readFile(apiFile, 'utf8');
|
||
restoreFiles.push({ path: apiFile, content: apiFileContent });
|
||
restoreFiles.push({ path: stepFile, content: '' });
|
||
|
||
content = `export async function devTestStepChangeBase() {
|
||
'use step';
|
||
return 'base';
|
||
}
|
||
`;
|
||
await fs.writeFile(stepFile, content);
|
||
await fs.writeFile(
|
||
apiFile,
|
||
`import * as workflow_dev_test_step_change from '${finalConfig.apiFileImportPath}/${workflowsDir}/dev-test-step-change';
|
||
${apiFileContent.replace(
|
||
'export const allWorkflows = {\n',
|
||
`export const allWorkflows = {
|
||
'${workflowsDir}/dev-test-step-change.ts': workflow_dev_test_step_change,
|
||
`
|
||
)}`
|
||
);
|
||
await pollUntil({
|
||
description: 'step-change fixture to appear in manifest',
|
||
timeoutMs: 50_000,
|
||
check: async () => {
|
||
await prewarm();
|
||
expect(await readManifestStepFunctionNames()).toContain(
|
||
'devTestStepChangeBase'
|
||
);
|
||
},
|
||
});
|
||
}
|
||
|
||
await fs.writeFile(
|
||
stepFile,
|
||
`${content}
|
||
|
||
export async function myNewStep() {
|
||
'use step'
|
||
return 'hello world'
|
||
}
|
||
`
|
||
);
|
||
if (!usesNextFlowRoute) {
|
||
restoreFiles.push({ path: stepFile, content });
|
||
}
|
||
await pollUntil({
|
||
description: 'generated step outputs to include myNewStep',
|
||
timeoutMs: usesNextFlowRoute ? 50_000 : 25_000,
|
||
check: async () => {
|
||
const stepRegistrationContent = await readFileIfExists(
|
||
generatedStepRegistration
|
||
);
|
||
if (stepRegistrationContent?.includes('myNewStep')) {
|
||
return;
|
||
}
|
||
|
||
// Next flow-route builders regenerate manifest.json on every
|
||
// rebuild. The bundled file may not preserve function names as
|
||
// plain text.
|
||
if (usesNextFlowRoute) {
|
||
await prewarm();
|
||
const manifestFunctionNames =
|
||
await readManifestStepFunctionNames();
|
||
expect(manifestFunctionNames).toContain('myNewStep');
|
||
return;
|
||
}
|
||
|
||
throw new Error('myNewStep not found in generated step outputs');
|
||
},
|
||
});
|
||
}
|
||
);
|
||
|
||
test.runIf(process.env.APP_NAME === 'vite')(
|
||
'should execute updated step logic after HMR',
|
||
{ timeout: 70_000 },
|
||
async () => {
|
||
assert(deploymentUrl);
|
||
setupWorld(deploymentUrl);
|
||
|
||
const workflowFile = path.join(appPath, workflowsDir, testWorkflowFile);
|
||
const content = await fs.readFile(workflowFile, 'utf8');
|
||
const before = 'before HMR';
|
||
const after = 'after HMR';
|
||
const fixture = `
|
||
export async function hmrWorkflow() {
|
||
'use workflow';
|
||
return hmrStep();
|
||
}
|
||
|
||
async function hmrStep() {
|
||
'use step';
|
||
return '${before}';
|
||
}
|
||
`;
|
||
|
||
await fs.writeFile(workflowFile, content + fixture);
|
||
restoreFiles.push({ path: workflowFile, content });
|
||
|
||
await pollUntil({
|
||
description: 'generated step output to include the HMR fixture',
|
||
check: async () => {
|
||
expect(
|
||
await fs.readFile(generatedStepRegistration, 'utf8')
|
||
).toContain(before);
|
||
},
|
||
});
|
||
|
||
const workflow = await getWorkflowMetadata(
|
||
deploymentUrl,
|
||
`workflows/${testWorkflowFile}`,
|
||
'hmrWorkflow'
|
||
);
|
||
const runBefore = await start<[], string>(workflow, []);
|
||
expect(await runBefore.returnValue).toBe(before);
|
||
|
||
await fs.writeFile(
|
||
workflowFile,
|
||
(content + fixture).replace(before, after)
|
||
);
|
||
|
||
await pollUntil({
|
||
description: 'generated step output to include the HMR update',
|
||
check: async () => {
|
||
expect(
|
||
await fs.readFile(generatedStepRegistration, 'utf8')
|
||
).toContain(after);
|
||
},
|
||
});
|
||
|
||
const runAfter = await start<[], string>(workflow, []);
|
||
expect(await runAfter.returnValue).toBe(after);
|
||
}
|
||
);
|
||
|
||
test(
|
||
'should rebuild on adding workflow file',
|
||
{ timeout: hmrTestTimeoutMs },
|
||
async () => {
|
||
if (usesNextFlowRoute) {
|
||
await waitForHmrReady();
|
||
}
|
||
|
||
const workflowFile = path.join(
|
||
appPath,
|
||
workflowsDir,
|
||
'new-workflow.ts'
|
||
);
|
||
|
||
await fs.writeFile(
|
||
workflowFile,
|
||
`export async function newWorkflowFile() {
|
||
'use workflow'
|
||
return 'hello world'
|
||
}
|
||
`
|
||
);
|
||
restoreFiles.push({ path: workflowFile, content: '' });
|
||
const apiFile = path.join(appPath, finalConfig.apiFilePath);
|
||
|
||
const apiFileContent = await fs.readFile(apiFile, 'utf8');
|
||
restoreFiles.push({ path: apiFile, content: apiFileContent });
|
||
|
||
await fs.writeFile(
|
||
apiFile,
|
||
`import '${finalConfig.apiFileImportPath}/${workflowsDir}/new-workflow';
|
||
${apiFileContent}`
|
||
);
|
||
|
||
await pollUntil({
|
||
description: 'generated workflow to include newWorkflowFile',
|
||
timeoutMs: hmrRediscoveryTimeoutMs,
|
||
check: async () => {
|
||
if (usesNextFlowRoute) {
|
||
const manifestJson = await fs.readFile(
|
||
workflowManifestPath,
|
||
'utf8'
|
||
);
|
||
const manifest = JSON.parse(manifestJson) as {
|
||
workflows?: Record<string, Record<string, unknown>>;
|
||
};
|
||
expect(
|
||
Object.values(manifest.workflows || {}).some((workflows) =>
|
||
Object.hasOwn(workflows, 'newWorkflowFile')
|
||
)
|
||
).toBe(true);
|
||
return;
|
||
}
|
||
|
||
await fetchWithTimeout('/api/chat');
|
||
const workflowContent = await readGeneratedWorkflowOutput();
|
||
expect(workflowContent).toContain('newWorkflowFile');
|
||
},
|
||
});
|
||
}
|
||
);
|
||
|
||
test.runIf(process.env.APP_NAME === 'nextjs-turbopack')(
|
||
'should not log source map warnings for workflow node_modules imports',
|
||
{ timeout: hmrTestTimeoutMs },
|
||
async () => {
|
||
const packageDir = path.join(
|
||
appPath,
|
||
'node_modules',
|
||
SOURCE_MAP_FIXTURE_PACKAGE
|
||
);
|
||
const packageJsonPath = path.join(packageDir, 'package.json');
|
||
const packageIndexPath = path.join(packageDir, 'index.js');
|
||
const workflowFile = path.join(
|
||
appPath,
|
||
workflowsDir,
|
||
'source-map-warning-fixture.ts'
|
||
);
|
||
const apiFile = path.join(appPath, finalConfig.apiFilePath);
|
||
const apiFileContent = await fs.readFile(apiFile, 'utf8');
|
||
|
||
await fs.mkdir(packageDir, { recursive: true });
|
||
restoreDirectories.push(packageDir);
|
||
await fs.writeFile(
|
||
packageJsonPath,
|
||
JSON.stringify(
|
||
{
|
||
name: SOURCE_MAP_FIXTURE_PACKAGE,
|
||
version: '0.0.0',
|
||
type: 'module',
|
||
main: './index.js',
|
||
types: './index.d.ts',
|
||
},
|
||
null,
|
||
2
|
||
)
|
||
);
|
||
await fs.writeFile(
|
||
packageIndexPath,
|
||
`export const sourceMapWarningFixtureValue = Symbol.for('workflow-serialize').description ?? 'workflow-serialize';
|
||
${SOURCE_MAP_COMMENT}
|
||
`
|
||
);
|
||
await fs.writeFile(
|
||
path.join(packageDir, 'index.d.ts'),
|
||
`export declare const sourceMapWarningFixtureValue: string;
|
||
`
|
||
);
|
||
await fs.writeFile(
|
||
workflowFile,
|
||
`import { sourceMapWarningFixtureValue } from '${SOURCE_MAP_FIXTURE_PACKAGE}';
|
||
|
||
async function readSourceMapWarningFixture() {
|
||
'use step';
|
||
return sourceMapWarningFixtureValue;
|
||
}
|
||
|
||
export async function sourceMapWarningFixtureWorkflow() {
|
||
'use workflow';
|
||
return readSourceMapWarningFixture();
|
||
}
|
||
`
|
||
);
|
||
restoreFiles.push({ path: workflowFile, content: '' });
|
||
restoreFiles.push({ path: apiFile, content: apiFileContent });
|
||
|
||
await fs.writeFile(
|
||
apiFile,
|
||
`import '${finalConfig.apiFileImportPath}/${workflowsDir}/source-map-warning-fixture';
|
||
${apiFileContent}`
|
||
);
|
||
|
||
await pollUntil({
|
||
description:
|
||
'generated workflow to include sourceMapWarningFixtureWorkflow',
|
||
timeoutMs: hmrRediscoveryTimeoutMs,
|
||
check: async () => {
|
||
if (usesNextFlowRoute) {
|
||
const manifestFunctionNames =
|
||
await readManifestWorkflowFunctionNames();
|
||
expect(manifestFunctionNames).toContain(
|
||
'sourceMapWarningFixtureWorkflow'
|
||
);
|
||
return;
|
||
}
|
||
|
||
await fetchWithTimeout('/api/chat');
|
||
const workflowContent = await readGeneratedWorkflowOutput();
|
||
expect(workflowContent).toContain(
|
||
'sourceMapWarningFixtureWorkflow'
|
||
);
|
||
},
|
||
});
|
||
|
||
if (devServerLogPath) {
|
||
const log = await fs.readFile(devServerLogPath, 'utf8');
|
||
expect(log).not.toContain(SOURCE_MAP_WARNING);
|
||
}
|
||
}
|
||
);
|
||
|
||
test.runIf(shouldRunNextFlowRouteHmrTests)(
|
||
'should follow Next flow-route HMR rebuild rules for body-only changes',
|
||
{ timeout: flowRouteHmrFuzzTimeoutMs },
|
||
async () => {
|
||
assert(deploymentUrl);
|
||
setupWorld(deploymentUrl);
|
||
|
||
const apiFile = path.join(appPath, finalConfig.apiFilePath);
|
||
const apiFileContent = await fs.readFile(apiFile, 'utf8');
|
||
restoreFiles.push({ path: apiFile, content: apiFileContent });
|
||
|
||
const files = {
|
||
workflow: path.join(appPath, workflowsDir, 'hmr-fuzz-workflow.ts'),
|
||
workflowHelper: path.join(
|
||
appPath,
|
||
workflowsDir,
|
||
'hmr-fuzz-workflow-helper.ts'
|
||
),
|
||
step: path.join(appPath, workflowsDir, 'hmr-fuzz-step.ts'),
|
||
stepHelper: path.join(
|
||
appPath,
|
||
workflowsDir,
|
||
'hmr-fuzz-step-helper.ts'
|
||
),
|
||
sharedHelper: path.join(
|
||
appPath,
|
||
workflowsDir,
|
||
'hmr-fuzz-shared-helper.ts'
|
||
),
|
||
serde: path.join(appPath, workflowsDir, 'hmr-fuzz-serde.ts'),
|
||
importHelper: path.join(
|
||
appPath,
|
||
workflowsDir,
|
||
'hmr-fuzz-import-helper.ts'
|
||
),
|
||
addedWorkflow: path.join(
|
||
appPath,
|
||
workflowsDir,
|
||
'hmr-fuzz-added-workflow.ts'
|
||
),
|
||
unrelated: path.join(appPath, workflowsDir, 'hmr-fuzz-unrelated.ts'),
|
||
};
|
||
for (const file of Object.values(files)) {
|
||
restoreFiles.push({ path: file, content: '' });
|
||
}
|
||
|
||
await waitForHmrReady();
|
||
|
||
const writeFuzzSources = async (iteration: number) => {
|
||
await Promise.all([
|
||
fs.writeFile(
|
||
files.workflow,
|
||
`import { HmrFuzzBox } from './hmr-fuzz-serde';
|
||
import { hmrFuzzSharedHelper } from './hmr-fuzz-shared-helper';
|
||
import { hmrFuzzStep } from './hmr-fuzz-step';
|
||
import { hmrFuzzWorkflowHelper } from './hmr-fuzz-workflow-helper';
|
||
|
||
export async function hmrFuzzWorkflow() {
|
||
'use workflow';
|
||
const stepValue = await hmrFuzzStep();
|
||
const workflowValue = hmrFuzzWorkflowHelper(
|
||
new HmrFuzzBox(hmrFuzzSharedHelper('workflow-${iteration}'))
|
||
);
|
||
return { stepValue, workflowValue };
|
||
}
|
||
`
|
||
),
|
||
fs.writeFile(
|
||
files.workflowHelper,
|
||
`import { HmrFuzzBox } from './hmr-fuzz-serde';
|
||
|
||
export function hmrFuzzWorkflowHelper(value: HmrFuzzBox) {
|
||
return value.label + '-workflow-helper-${iteration}';
|
||
}
|
||
`
|
||
),
|
||
fs.writeFile(
|
||
files.step,
|
||
`import { hmrFuzzSharedHelper } from './hmr-fuzz-shared-helper';
|
||
import { hmrFuzzStepHelper } from './hmr-fuzz-step-helper';
|
||
|
||
export async function hmrFuzzStep() {
|
||
'use step';
|
||
return hmrFuzzSharedHelper(hmrFuzzStepHelper()) + '-step-${iteration}';
|
||
}
|
||
`
|
||
),
|
||
fs.writeFile(
|
||
files.stepHelper,
|
||
`export function hmrFuzzStepHelper() {
|
||
return 'step-helper-${iteration}';
|
||
}
|
||
`
|
||
),
|
||
fs.writeFile(
|
||
files.sharedHelper,
|
||
`export function hmrFuzzSharedHelper(value: string) {
|
||
return value + '-shared-${iteration}';
|
||
}
|
||
`
|
||
),
|
||
fs.writeFile(
|
||
files.serde,
|
||
`export class HmrFuzzBox {
|
||
static classId = 'HmrFuzzBox';
|
||
|
||
constructor(public label: string) {}
|
||
|
||
static [Symbol.for('workflow-serialize')](value: HmrFuzzBox) {
|
||
return { label: value.label + '-serde-${iteration}' };
|
||
}
|
||
|
||
static [Symbol.for('workflow-deserialize')](value: { label: string }) {
|
||
return new HmrFuzzBox(value.label);
|
||
}
|
||
}
|
||
`
|
||
),
|
||
fs.writeFile(
|
||
files.importHelper,
|
||
"export const hmrFuzzImportedValue = 'imported-stable';\n"
|
||
),
|
||
]);
|
||
};
|
||
|
||
await writeFuzzSources(0);
|
||
await fs.writeFile(
|
||
apiFile,
|
||
`import '${finalConfig.apiFileImportPath}/${workflowsDir}/hmr-fuzz-step';
|
||
import '${finalConfig.apiFileImportPath}/${workflowsDir}/hmr-fuzz-workflow';
|
||
${apiFileContent}`
|
||
);
|
||
|
||
await pollUntil({
|
||
description: 'HMR fuzz fixture to appear in the Next manifest',
|
||
timeoutMs: flowRouteHmrRediscoveryTimeoutMs,
|
||
check: async () => {
|
||
await prewarm();
|
||
expect(await readManifestStepFunctionNames()).toContain(
|
||
'hmrFuzzStep'
|
||
);
|
||
expect(await readManifestWorkflowFunctionNames()).toContain(
|
||
'hmrFuzzWorkflow'
|
||
);
|
||
},
|
||
});
|
||
|
||
let workflow:
|
||
| Awaited<ReturnType<typeof getWorkflowMetadata>>
|
||
| undefined;
|
||
await pollUntil({
|
||
description: 'HMR fuzz workflow metadata to be readable',
|
||
timeoutMs: 50_000,
|
||
intervalMs: 500,
|
||
check: async () => {
|
||
workflow = await getWorkflowMetadata(
|
||
deploymentUrl,
|
||
`${workflowsDir}/hmr-fuzz-workflow.ts`,
|
||
'hmrFuzzWorkflow'
|
||
);
|
||
},
|
||
});
|
||
assert(workflow);
|
||
const runWorkflow = async () => {
|
||
const run = await start<
|
||
[],
|
||
{ stepValue: string; workflowValue: string }
|
||
>(workflow, []);
|
||
return await run.returnValue;
|
||
};
|
||
const expectWorkflowResult = async ({
|
||
description,
|
||
stepValue,
|
||
workflowValue,
|
||
}: {
|
||
description: string;
|
||
stepValue?: string;
|
||
workflowValue?: string;
|
||
}) => {
|
||
await pollUntil({
|
||
description,
|
||
timeoutMs: 90_000,
|
||
intervalMs: 500,
|
||
check: async () => {
|
||
const result = await runWorkflow();
|
||
if (stepValue) {
|
||
expect(result.stepValue).toContain(stepValue);
|
||
}
|
||
if (workflowValue) {
|
||
expect(result.workflowValue).toContain(workflowValue);
|
||
}
|
||
},
|
||
});
|
||
};
|
||
|
||
let snapshot = await waitForGeneratedArtifactStability();
|
||
const cases = [
|
||
{
|
||
file: files.step,
|
||
kind: 'none',
|
||
expectedLogCounts: { skip: 1 },
|
||
expectedStepValue: (iteration: number) => `step-only-${iteration}`,
|
||
source: (
|
||
iteration: number
|
||
) => `import { hmrFuzzSharedHelper } from './hmr-fuzz-shared-helper';
|
||
import { hmrFuzzStepHelper } from './hmr-fuzz-step-helper';
|
||
|
||
export async function hmrFuzzStep() {
|
||
'use step';
|
||
return hmrFuzzSharedHelper(hmrFuzzStepHelper()) + '-step-only-${iteration}';
|
||
}
|
||
`,
|
||
},
|
||
{
|
||
file: files.stepHelper,
|
||
kind: 'none',
|
||
expectedLogCounts: { skip: 1 },
|
||
expectedStepValue: (iteration: number) =>
|
||
`step-helper-only-${iteration}`,
|
||
source: (
|
||
iteration: number
|
||
) => `export function hmrFuzzStepHelper() {
|
||
return 'step-helper-only-${iteration}';
|
||
}
|
||
`,
|
||
},
|
||
{
|
||
file: files.workflow,
|
||
kind: 'workflow',
|
||
expectedLogCounts: { hot: 1 },
|
||
expectedWorkflowValue: (iteration: number) =>
|
||
`workflow-body-${iteration}`,
|
||
source: (
|
||
iteration: number
|
||
) => `import { HmrFuzzBox } from './hmr-fuzz-serde';
|
||
import { hmrFuzzSharedHelper } from './hmr-fuzz-shared-helper';
|
||
import { hmrFuzzStep } from './hmr-fuzz-step';
|
||
import { hmrFuzzWorkflowHelper } from './hmr-fuzz-workflow-helper';
|
||
|
||
export async function hmrFuzzWorkflow() {
|
||
'use workflow';
|
||
const stepValue = await hmrFuzzStep();
|
||
const workflowValue = hmrFuzzWorkflowHelper(
|
||
new HmrFuzzBox(hmrFuzzSharedHelper('workflow-body-${iteration}'))
|
||
);
|
||
return { stepValue, workflowValue };
|
||
}
|
||
`,
|
||
},
|
||
{
|
||
file: files.workflowHelper,
|
||
kind: 'workflow',
|
||
expectedLogCounts: { hot: 1 },
|
||
expectedWorkflowValue: (iteration: number) =>
|
||
`workflow-helper-body-${iteration}`,
|
||
source: (
|
||
iteration: number
|
||
) => `import { HmrFuzzBox } from './hmr-fuzz-serde';
|
||
|
||
export function hmrFuzzWorkflowHelper(value: HmrFuzzBox) {
|
||
return value.label + '-workflow-helper-body-${iteration}';
|
||
}
|
||
`,
|
||
},
|
||
{
|
||
file: files.sharedHelper,
|
||
kind: 'workflow',
|
||
expectedLogCounts: { hot: 1 },
|
||
expectedStepValue: (iteration: number) =>
|
||
`shared-body-${iteration}`,
|
||
expectedWorkflowValue: (iteration: number) =>
|
||
`shared-body-${iteration}`,
|
||
source: (
|
||
iteration: number
|
||
) => `export function hmrFuzzSharedHelper(value: string) {
|
||
return value + '-shared-body-${iteration}';
|
||
}
|
||
`,
|
||
},
|
||
{
|
||
file: files.serde,
|
||
kind: 'serde',
|
||
expectedLogCounts: { hot: 1 },
|
||
source: (iteration: number) => `export class HmrFuzzBox {
|
||
static classId = 'HmrFuzzBox';
|
||
|
||
constructor(public label: string) {}
|
||
|
||
static [Symbol.for('workflow-serialize')](value: HmrFuzzBox) {
|
||
return { label: value.label + '-serde-body-${iteration}' };
|
||
}
|
||
|
||
static [Symbol.for('workflow-deserialize')](value: { label: string }) {
|
||
return new HmrFuzzBox(value.label);
|
||
}
|
||
}
|
||
`,
|
||
},
|
||
] as const;
|
||
// Next canary has been flaky for transitive workflow-helper execution
|
||
// updates; stable still covers that HMR path.
|
||
const casesToRun = finalConfig.canary
|
||
? cases.filter((testCase) => testCase.file !== files.workflowHelper)
|
||
: cases;
|
||
|
||
for (let index = 0; index < casesToRun.length; index++) {
|
||
const iteration = index + 1;
|
||
const testCase = casesToRun[index];
|
||
const previousSnapshot = snapshot;
|
||
const logCursor = await readDevServerLogCursor();
|
||
await fs.writeFile(testCase.file, testCase.source(iteration));
|
||
|
||
// Next canary can keep executing a stale workflow bundle after the
|
||
// workflow hot-rebuild completed. Stable still covers execution
|
||
// correctness; canary keeps covering classification/log/artifact
|
||
// behavior for these changes.
|
||
if (!(finalConfig.canary && testCase.kind === 'workflow')) {
|
||
await expectWorkflowResult({
|
||
description: `${testCase.kind} HMR update to affect workflow execution`,
|
||
stepValue:
|
||
'expectedStepValue' in testCase
|
||
? testCase.expectedStepValue(iteration)
|
||
: undefined,
|
||
workflowValue:
|
||
'expectedWorkflowValue' in testCase
|
||
? testCase.expectedWorkflowValue(iteration)
|
||
: undefined,
|
||
});
|
||
}
|
||
|
||
if (testCase.kind === 'none') {
|
||
await expectHmrLogCounts(logCursor, testCase.expectedLogCounts);
|
||
snapshot = await waitForGeneratedArtifactStability();
|
||
continue;
|
||
}
|
||
|
||
snapshot = await waitForGeneratedArtifactStability();
|
||
if (testCase.kind === 'workflow') {
|
||
expect(snapshot.stepMtimeMs).toBe(previousSnapshot.stepMtimeMs);
|
||
} else {
|
||
expect(snapshot.stepMtimeMs).toBeGreaterThanOrEqual(
|
||
previousSnapshot.stepMtimeMs
|
||
);
|
||
}
|
||
await expectHmrLogCounts(logCursor, testCase.expectedLogCounts);
|
||
}
|
||
|
||
const fullCases = [
|
||
{
|
||
description: 'workflow import graph change',
|
||
write: async () => {
|
||
await fs.writeFile(
|
||
files.workflow,
|
||
`import { hmrFuzzImportedValue } from './hmr-fuzz-import-helper';
|
||
import { HmrFuzzBox } from './hmr-fuzz-serde';
|
||
import { hmrFuzzSharedHelper } from './hmr-fuzz-shared-helper';
|
||
import { hmrFuzzStep } from './hmr-fuzz-step';
|
||
import { hmrFuzzWorkflowHelper } from './hmr-fuzz-workflow-helper';
|
||
|
||
export async function hmrFuzzWorkflow() {
|
||
'use workflow';
|
||
const stepValue = await hmrFuzzStep();
|
||
const workflowValue = hmrFuzzWorkflowHelper(
|
||
new HmrFuzzBox(hmrFuzzSharedHelper(hmrFuzzImportedValue))
|
||
);
|
||
return { stepValue, workflowValue };
|
||
}
|
||
`
|
||
);
|
||
},
|
||
assert: async () => {
|
||
if (finalConfig.canary) {
|
||
return;
|
||
}
|
||
await expectWorkflowResult({
|
||
description:
|
||
'workflow import graph full rediscovery to affect execution',
|
||
workflowValue: 'imported-stable',
|
||
});
|
||
},
|
||
},
|
||
{
|
||
description: 'step definition added',
|
||
write: async (iteration: number) => {
|
||
await fs.writeFile(
|
||
files.step,
|
||
`import { hmrFuzzSharedHelper } from './hmr-fuzz-shared-helper';
|
||
import { hmrFuzzStepHelper } from './hmr-fuzz-step-helper';
|
||
|
||
export async function hmrFuzzStep() {
|
||
'use step';
|
||
return hmrFuzzSharedHelper(hmrFuzzStepHelper()) + '-step-full-${iteration}';
|
||
}
|
||
|
||
export async function hmrFuzzAddedStep() {
|
||
'use step';
|
||
return 'added-step-${iteration}';
|
||
}
|
||
`
|
||
);
|
||
},
|
||
assert: async () => {
|
||
await pollUntil({
|
||
description: 'added step definition to appear in manifest',
|
||
timeoutMs: flowRouteHmrRediscoveryTimeoutMs,
|
||
intervalMs: 500,
|
||
check: async () => {
|
||
await prewarm();
|
||
expect(await readManifestStepFunctionNames()).toContain(
|
||
'hmrFuzzAddedStep'
|
||
);
|
||
},
|
||
});
|
||
},
|
||
},
|
||
{
|
||
description: 'workflow definition added',
|
||
write: async (iteration: number) => {
|
||
await fs.writeFile(
|
||
files.workflow,
|
||
`import { hmrFuzzImportedValue } from './hmr-fuzz-import-helper';
|
||
import { HmrFuzzBox } from './hmr-fuzz-serde';
|
||
import { hmrFuzzSharedHelper } from './hmr-fuzz-shared-helper';
|
||
import { hmrFuzzStep } from './hmr-fuzz-step';
|
||
import { hmrFuzzWorkflowHelper } from './hmr-fuzz-workflow-helper';
|
||
|
||
export async function hmrFuzzWorkflow() {
|
||
'use workflow';
|
||
const stepValue = await hmrFuzzStep();
|
||
const workflowValue = hmrFuzzWorkflowHelper(
|
||
new HmrFuzzBox(hmrFuzzSharedHelper(hmrFuzzImportedValue))
|
||
);
|
||
return { stepValue, workflowValue };
|
||
}
|
||
|
||
export async function hmrFuzzAddedWorkflow() {
|
||
'use workflow';
|
||
return 'added-workflow-${iteration}';
|
||
}
|
||
`
|
||
);
|
||
},
|
||
assert: async () => {
|
||
await pollUntil({
|
||
description: 'added workflow definition to appear in manifest',
|
||
timeoutMs: flowRouteHmrRediscoveryTimeoutMs,
|
||
intervalMs: 500,
|
||
check: async () => {
|
||
await prewarm();
|
||
expect(await readManifestWorkflowFunctionNames()).toContain(
|
||
'hmrFuzzAddedWorkflow'
|
||
);
|
||
},
|
||
});
|
||
},
|
||
},
|
||
{
|
||
description: 'workflow file added through API import',
|
||
write: async (iteration: number) => {
|
||
await fs.writeFile(
|
||
files.addedWorkflow,
|
||
`export async function hmrFuzzAddedFileWorkflow() {
|
||
'use workflow';
|
||
return 'added-file-workflow-${iteration}';
|
||
}
|
||
`
|
||
);
|
||
await fs.writeFile(
|
||
apiFile,
|
||
`import '${finalConfig.apiFileImportPath}/${workflowsDir}/hmr-fuzz-added-workflow';
|
||
import '${finalConfig.apiFileImportPath}/${workflowsDir}/hmr-fuzz-step';
|
||
import '${finalConfig.apiFileImportPath}/${workflowsDir}/hmr-fuzz-workflow';
|
||
${apiFileContent}`
|
||
);
|
||
},
|
||
assert: async () => {
|
||
await pollUntil({
|
||
description: 'added workflow file to appear in manifest',
|
||
timeoutMs: flowRouteHmrRediscoveryTimeoutMs,
|
||
intervalMs: 500,
|
||
check: async () => {
|
||
await prewarm();
|
||
expect(await readManifestWorkflowFunctionNames()).toContain(
|
||
'hmrFuzzAddedFileWorkflow'
|
||
);
|
||
},
|
||
});
|
||
},
|
||
},
|
||
{
|
||
description: 'workflow file removed from API import',
|
||
expectedLogCounts: { full: 1, skip: 1 },
|
||
write: async () => {
|
||
await fs.rm(files.addedWorkflow, { force: true });
|
||
await fs.writeFile(
|
||
apiFile,
|
||
`import '${finalConfig.apiFileImportPath}/${workflowsDir}/hmr-fuzz-step';
|
||
import '${finalConfig.apiFileImportPath}/${workflowsDir}/hmr-fuzz-workflow';
|
||
${apiFileContent}`
|
||
);
|
||
},
|
||
assert: async () => {
|
||
await pollUntil({
|
||
description: 'removed workflow file to disappear from manifest',
|
||
timeoutMs: flowRouteHmrRediscoveryTimeoutMs,
|
||
intervalMs: 500,
|
||
check: async () => {
|
||
await prewarm();
|
||
expect(
|
||
await readManifestWorkflowFunctionNames()
|
||
).not.toContain('hmrFuzzAddedFileWorkflow');
|
||
},
|
||
});
|
||
},
|
||
},
|
||
] as const;
|
||
|
||
for (let index = 0; index < fullCases.length; index++) {
|
||
const fullCase = fullCases[index];
|
||
const logCursor = await readDevServerLogCursor();
|
||
await fullCase.write(index + 1);
|
||
await fullCase.assert(index + 1);
|
||
await expectHmrLogCounts(
|
||
logCursor,
|
||
'expectedLogCounts' in fullCase
|
||
? fullCase.expectedLogCounts
|
||
: { full: 1 }
|
||
);
|
||
snapshot = await waitForGeneratedArtifactStability();
|
||
}
|
||
|
||
const unrelatedLogCursor = await readDevServerLogCursor();
|
||
await fs.writeFile(files.unrelated, 'export const unrelated = true;\n');
|
||
snapshot = await expectGeneratedArtifactsUnchanged(snapshot);
|
||
await expectHmrLogCounts(unrelatedLogCursor, { skip: 1 });
|
||
|
||
const unrelatedRemovalLogCursor = await readDevServerLogCursor();
|
||
await fs.unlink(files.unrelated);
|
||
snapshot = await expectGeneratedArtifactsUnchanged(snapshot);
|
||
await expectHmrLogCounts(unrelatedRemovalLogCursor, { skip: 1 });
|
||
}
|
||
);
|
||
});
|
||
}
|
||
|
||
// Run tests with environment-based config if this file is executed directly
|
||
if (process.env.DEV_TEST_CONFIG) {
|
||
createDevTests();
|
||
}
|