diff --git a/.changeset/hot-dolphins-obey.md b/.changeset/hot-dolphins-obey.md new file mode 100644 index 000000000000..7bb9fed4c6e0 --- /dev/null +++ b/.changeset/hot-dolphins-obey.md @@ -0,0 +1,5 @@ +--- +"@cloudflare/vitest-pool-workers": patch +--- + +fix: ensures Vitest Pool Workers doesn't error when using nodejs_compat_v2 flag diff --git a/packages/vitest-pool-workers/src/pool/compatibility-flag-assertions.ts b/packages/vitest-pool-workers/src/pool/compatibility-flag-assertions.ts new file mode 100644 index 000000000000..287ec3f53d67 --- /dev/null +++ b/packages/vitest-pool-workers/src/pool/compatibility-flag-assertions.ts @@ -0,0 +1,164 @@ +/** + * The `CompatibilityFlagAssertions` class provides methods to validate compatibility flags and dates + * within a project's configuration. It ensures that specific flags are either present + * or absent and that compatibility dates meet the required criteria. + */ +export class CompatibilityFlagAssertions { + #compatibilityDate?: string; + #compatibilityFlags: string[]; + #optionsPath: string; + #relativeProjectPath: string; + #relativeWranglerConfigPath?: string; + + constructor(options: CommonOptions) { + this.#compatibilityDate = options.compatibilityDate; + this.#compatibilityFlags = options.compatibilityFlags; + this.#optionsPath = options.optionsPath; + this.#relativeProjectPath = options.relativeProjectPath; + this.#relativeWranglerConfigPath = options.relativeWranglerConfigPath; + } + + /** + * Checks if a specific flag is present in the compatibilityFlags array. + */ + #flagExists(flag: string): boolean { + return this.#compatibilityFlags.includes(flag); + } + + /** + * Constructs the base of the error message. + * + * @example + * In project /path/to/project + * + * @example + * In project /path/to/project's configuration file wrangler.toml + */ + #buildErrorMessageBase(): string { + let message = `In project ${this.#relativeProjectPath}`; + if (this.#relativeWranglerConfigPath) { + message += `'s configuration file ${this.#relativeWranglerConfigPath}`; + } + return message; + } + + /** + * Constructs the configuration path part of the error message. + */ + #buildConfigPath(setting: string): string { + if (this.#relativeWranglerConfigPath) { + return `\`${setting}\``; + } + + const camelCaseSetting = setting.replace(/_(\w)/g, (_, letter) => + letter.toUpperCase() + ); + + return `\`${this.#optionsPath}.${camelCaseSetting}\``; + } + + /** + * Ensures that a specific enable flag is present or that the compatibility date meets the required date. + */ + assertIsEnabled({ + enableFlag, + disableFlag, + defaultOnDate, + }: { + enableFlag: string; + disableFlag: string; + defaultOnDate?: string; + }): AssertionResult { + // If it's disabled by this flag, we can return early. + if (this.#flagExists(disableFlag)) { + const errorMessage = `${this.#buildErrorMessageBase()}, ${this.#buildConfigPath( + "compatibility_flags" + )} must not contain "${disableFlag}".\nThis flag is incompatible with \`@cloudflare/vitest-pool-workers\`.`; + return { isValid: false, errorMessage }; + } + + const enableFlagPresent = this.#flagExists(enableFlag); + const dateSufficient = isDateSufficient( + this.#compatibilityDate, + defaultOnDate + ); + + if (!enableFlagPresent && !dateSufficient) { + let errorMessage = `${this.#buildErrorMessageBase()}, ${this.#buildConfigPath( + "compatibility_flags" + )} must contain "${enableFlag}"`; + + if (defaultOnDate) { + errorMessage += `, or ${this.#buildConfigPath( + "compatibility_date" + )} must be >= "${defaultOnDate}".`; + } + + errorMessage += `\nThis flag is required to use \`@cloudflare/vitest-pool-workers\`.`; + + return { isValid: false, errorMessage }; + } + + return { isValid: true }; + } + + /** + * Ensures that a any one of a given set of flags is present in the compatibility_flags array. + */ + assertAtLeastOneFlagExists(flags: string[]): AssertionResult { + if (flags.length === 0 || flags.some((flag) => this.#flagExists(flag))) { + return { isValid: true }; + } + + const errorMessage = `${this.#buildErrorMessageBase()}, ${this.#buildConfigPath( + "compatibility_flags" + )} must contain one of ${flags.map((flag) => `"${flag}"`).join("/")}.\nEither one of these flags is required to use \`@cloudflare/vitest-pool-workers\`.`; + + return { isValid: false, errorMessage }; + } +} + +/** + * Common options used across all assertion methods. + */ +interface CommonOptions { + compatibilityDate?: string; + compatibilityFlags: string[]; + optionsPath: string; + relativeProjectPath: string; + relativeWranglerConfigPath?: string; +} + +/** + * Result of an assertion method. + */ +interface AssertionResult { + isValid: boolean; + errorMessage?: string; +} + +/** + * Parses a date string into a Date object. + */ +function parseDate(dateStr: string): Date { + const date = new Date(dateStr); + if (isNaN(date.getTime())) { + throw new Error(`Invalid date format: "${dateStr}"`); + } + return date; +} + +/** + * Checks if the compatibility date meets or exceeds the required date. + */ +function isDateSufficient( + compatibilityDate?: string, + defaultOnDate?: string +): boolean { + if (!compatibilityDate || !defaultOnDate) { + return false; + } + const compDate = parseDate(compatibilityDate); + const reqDate = parseDate(defaultOnDate); + return compDate >= reqDate; +} diff --git a/packages/vitest-pool-workers/src/pool/index.ts b/packages/vitest-pool-workers/src/pool/index.ts index 4da971af9b34..19ec83497c36 100644 --- a/packages/vitest-pool-workers/src/pool/index.ts +++ b/packages/vitest-pool-workers/src/pool/index.ts @@ -23,6 +23,7 @@ import { import semverSatisfies from "semver/functions/satisfies.js"; import { createMethodsRPC } from "vitest/node"; import { createChunkingSocket } from "../shared/chunking-socket"; +import { CompatibilityFlagAssertions } from "./compatibility-flag-assertions"; import { OPTIONS_PATH, parseProjectOptions } from "./config"; import { getProjectPath, @@ -319,68 +320,6 @@ const SELF_SERVICE_BINDING = "__VITEST_POOL_WORKERS_SELF_SERVICE"; const LOOPBACK_SERVICE_BINDING = "__VITEST_POOL_WORKERS_LOOPBACK_SERVICE"; const RUNNER_OBJECT_BINDING = "__VITEST_POOL_WORKERS_RUNNER_OBJECT"; -const numericCompare = new Intl.Collator("en", { numeric: true }).compare; - -interface CompatibilityFlagCheckOptions { - // Context to check against - compatibilityFlags: string[]; - compatibilityDate?: string; - relativeProjectPath: string | number; - relativeWranglerConfigPath?: string; - - // Details on flag to check - enableFlag: string; - disableFlag?: string; - defaultOnDate?: string; -} -function assertCompatibilityFlagEnabled(opts: CompatibilityFlagCheckOptions) { - const hasWranglerConfig = opts.relativeWranglerConfigPath !== undefined; - - // Check disable flag (if any) not enabled - if ( - opts.disableFlag !== undefined && - opts.compatibilityFlags.includes(opts.disableFlag) - ) { - let message = `In project ${opts.relativeProjectPath}`; - if (hasWranglerConfig) { - message += `'s configuration file ${opts.relativeWranglerConfigPath}, \`compatibility_flags\` must not contain "${opts.disableFlag}".\nSimilarly`; - // Since the config is merged by this point, we don't know where the - // disable flag came from. So we include both possible locations in the - // error message. Note the enable-flag case doesn't have this problem, as - // we're asking the user to add something to *either* of their configs. - } - message += - `, \`${OPTIONS_PATH}.miniflare.compatibilityFlags\` must not contain "${opts.disableFlag}".\n` + - "This flag is incompatible with `@cloudflare/vitest-pool-workers`."; - throw new Error(message); - } - - // Check flag enabled or compatibility date enables flag by default - const enabledByFlag = opts.compatibilityFlags.includes(opts.enableFlag); - const enabledByDate = - opts.compatibilityDate !== undefined && - opts.defaultOnDate !== undefined && - numericCompare(opts.compatibilityDate, opts.defaultOnDate) >= 0; - if (!(enabledByFlag || enabledByDate)) { - let message = `In project ${opts.relativeProjectPath}`; - if (hasWranglerConfig) { - message += `'s configuration file ${opts.relativeWranglerConfigPath}, \`compatibility_flags\` must contain "${opts.enableFlag}"`; - } else { - message += `, \`${OPTIONS_PATH}.miniflare.compatibilityFlags\` must contain "${opts.enableFlag}"`; - } - if (opts.defaultOnDate !== undefined) { - if (hasWranglerConfig) { - message += `, or \`compatibility_date\` must be >= "${opts.defaultOnDate}"`; - } else { - message += `, or \`${OPTIONS_PATH}.miniflare.compatibilityDate\` must be >= "${opts.defaultOnDate}"`; - } - } - message += - ".\nThis flag is required to use `@cloudflare/vitest-pool-workers`."; - throw new Error(message); - } -} - function buildProjectWorkerOptions( project: Omit ): ProjectWorkers { @@ -400,24 +339,36 @@ function buildProjectWorkerOptions( // of the libraries it depends on expect `require()` to return // `module.exports` directly, rather than `{ default: module.exports }`. runnerWorker.compatibilityFlags ??= []; - assertCompatibilityFlagEnabled({ - compatibilityFlags: runnerWorker.compatibilityFlags, + + const flagAssertions = new CompatibilityFlagAssertions({ compatibilityDate: runnerWorker.compatibilityDate, - relativeProjectPath: project.relativePath, - relativeWranglerConfigPath, - // https://developers.cloudflare.com/workers/configuration/compatibility-dates/#commonjs-modules-do-not-export-a-module-namespace - enableFlag: "export_commonjs_default", - disableFlag: "export_commonjs_namespace", - defaultOnDate: "2022-10-31", - }); - assertCompatibilityFlagEnabled({ compatibilityFlags: runnerWorker.compatibilityFlags, - compatibilityDate: runnerWorker.compatibilityDate, - relativeProjectPath: project.relativePath, + optionsPath: `${OPTIONS_PATH}.miniflare`, + relativeProjectPath: project.relativePath.toString(), relativeWranglerConfigPath, - enableFlag: "nodejs_compat", }); + const assertions = [ + () => + flagAssertions.assertIsEnabled({ + enableFlag: "export_commonjs_default", + disableFlag: "export_commonjs_namespace", + defaultOnDate: "2022-10-31", + }), + () => + flagAssertions.assertAtLeastOneFlagExists([ + "nodejs_compat", + "nodejs_compat_v2", + ]), + ]; + + for (const assertion of assertions) { + const result = assertion(); + if (!result.isValid) { + throw new Error(result.errorMessage); + } + } + // Required for `workerd:unsafe` module. We don't require this flag to be set // as it's experimental, so couldn't be deployed by users. if (!runnerWorker.compatibilityFlags.includes("unsafe_module")) { diff --git a/packages/vitest-pool-workers/test/compatibility-flag-assertions.test.ts b/packages/vitest-pool-workers/test/compatibility-flag-assertions.test.ts new file mode 100644 index 000000000000..35c935791361 --- /dev/null +++ b/packages/vitest-pool-workers/test/compatibility-flag-assertions.test.ts @@ -0,0 +1,264 @@ +import { describe, expect, it } from "vitest"; +import { CompatibilityFlagAssertions } from "../src/pool/compatibility-flag-assertions"; + +describe("FlagAssertions", () => { + const baseOptions = { + optionsPath: "options", + relativeProjectPath: "/path/to/project", + }; + describe("assertDisableFlagNotPresent", () => { + it("returns error message when the flag is present", () => { + const options = { + ...baseOptions, + compatibilityFlags: ["disable-flag", "another-flag"], + }; + const flagAssertions = new CompatibilityFlagAssertions(options); + const result = flagAssertions.assertIsEnabled({ + disableFlag: "disable-flag", + enableFlag: "enable-flag", + }); + expect(result.isValid).toBe(false); + expect(result.errorMessage).toBe( + 'In project /path/to/project, `options.compatibilityFlags` must not contain "disable-flag".\nThis flag is incompatible with `@cloudflare/vitest-pool-workers`.' + ); + }); + + it("includes relativeWranglerConfigPath in error message when provided", () => { + const options = { + ...baseOptions, + compatibilityFlags: ["disable-flag"], + }; + const flagAssertions = new CompatibilityFlagAssertions(options); + const result = flagAssertions.assertIsEnabled({ + disableFlag: "disable-flag", + enableFlag: "enable-flag", + }); + expect(result.isValid).toBe(false); + expect(result.errorMessage).toBe( + 'In project /path/to/project, `options.compatibilityFlags` must not contain "disable-flag".\nThis flag is incompatible with `@cloudflare/vitest-pool-workers`.' + ); + }); + + it("correctly formats error message when relative Wrangler configPath is present", () => { + const options = { + ...baseOptions, + compatibilityFlags: ["disable-flag"], + relativeWranglerConfigPath: "wrangler.toml", + }; + const flagAssertions = new CompatibilityFlagAssertions(options); + const result = flagAssertions.assertIsEnabled({ + disableFlag: "disable-flag", + enableFlag: "enable-flag", + }); + expect(result.isValid).toBe(false); + expect(result.errorMessage).toBe( + 'In project /path/to/project\'s configuration file wrangler.toml, `compatibility_flags` must not contain "disable-flag".\nThis flag is incompatible with `@cloudflare/vitest-pool-workers`.' + ); + }); + }); + + describe("assertEnableFlagOrCompatibilityDate", () => { + it("returns true when the flag is present", () => { + const options = { + ...baseOptions, + compatibilityDate: "2022-12-31", + compatibilityFlags: ["enable-flag"], + }; + const flagAssertions = new CompatibilityFlagAssertions(options); + const result = flagAssertions.assertIsEnabled({ + defaultOnDate: "2023-01-01", + disableFlag: "disable-flag", + enableFlag: "enable-flag", + }); + expect(result.isValid).toBe(true); + }); + + it("returns true when compatibility date is sufficient", () => { + const options = { + ...baseOptions, + compatibilityDate: "2023-01-02", + compatibilityFlags: [], + }; + const flagAssertions = new CompatibilityFlagAssertions(options); + const result = flagAssertions.assertIsEnabled({ + disableFlag: "disable-flag", + enableFlag: "enable-flag", + defaultOnDate: "2023-01-01", + }); + expect(result.isValid).toBe(true); + }); + + it("returns error message when neither flag is present nor date is sufficient", () => { + const options = { + ...baseOptions, + compatibilityDate: "2022-12-31", + compatibilityFlags: [], + }; + const flagAssertions = new CompatibilityFlagAssertions(options); + const result = flagAssertions.assertIsEnabled({ + disableFlag: "disable-flag", + enableFlag: "enable-flag", + defaultOnDate: "2023-01-01", + }); + expect(result.isValid).toBe(false); + expect(result.errorMessage).toBe( + 'In project /path/to/project, `options.compatibilityFlags` must contain "enable-flag", or `options.compatibilityDate` must be >= "2023-01-01".\nThis flag is required to use `@cloudflare/vitest-pool-workers`.' + ); + }); + + it("returns error message when compatibilityDate is undefined", () => { + const options = { + ...baseOptions, + compatibilityDate: undefined, + compatibilityFlags: [], + }; + const flagAssertions = new CompatibilityFlagAssertions(options); + const result = flagAssertions.assertIsEnabled({ + disableFlag: "disable-flag", + enableFlag: "enable-flag", + defaultOnDate: "2023-01-01", + }); + expect(result.isValid).toBe(false); + expect(result.errorMessage).toBe( + 'In project /path/to/project, `options.compatibilityFlags` must contain "enable-flag", or `options.compatibilityDate` must be >= "2023-01-01".\nThis flag is required to use `@cloudflare/vitest-pool-workers`.' + ); + }); + + it("throws error when defaultOnDate is invalid", () => { + const options = { + ...baseOptions, + compatibilityDate: "2023-01-02", + compatibilityFlags: [], + }; + const flagAssertions = new CompatibilityFlagAssertions(options); + expect(() => { + flagAssertions.assertIsEnabled({ + disableFlag: "disable-flag", + enableFlag: "enable-flag", + defaultOnDate: "invalid-date", + }); + }).toThrowError('Invalid date format: "invalid-date"'); + }); + + it("throws error when compatibilityDate is invalid", () => { + const options = { + ...baseOptions, + compatibilityDate: "invalid-date", + compatibilityFlags: [], + }; + const flagAssertions = new CompatibilityFlagAssertions(options); + expect(() => { + flagAssertions.assertIsEnabled({ + disableFlag: "disable-flag", + enableFlag: "enable-flag", + defaultOnDate: "2023-01-01", + }); + }).toThrowError('Invalid date format: "invalid-date"'); + }); + }); + + describe("assertAtLeastOneFlagExists", () => { + it("returns true when at least one of the flags is present", () => { + const options = { + ...baseOptions, + compatibilityDate: "2020-01-01", + compatibilityFlags: ["flag1", "flag2"], + }; + const flagAssertions = new CompatibilityFlagAssertions(options); + const result = flagAssertions.assertAtLeastOneFlagExists(["flag1"]); + expect(result.isValid).toBe(true); + }); + + it("returns true when multiple flags are present", () => { + const options = { + ...baseOptions, + compatibilityDate: "2020-01-01", + compatibilityFlags: ["flag1", "flag2", "flag3"], + }; + const flagAssertions = new CompatibilityFlagAssertions(options); + const result = flagAssertions.assertAtLeastOneFlagExists([ + "flag2", + "flag3", + ]); + expect(result.isValid).toBe(true); + }); + + it("returns false when none of the flags are present", () => { + const options = { + ...baseOptions, + compatibilityDate: "2020-01-01", + compatibilityFlags: ["flag1"], + }; + const flagAssertions = new CompatibilityFlagAssertions(options); + const result = flagAssertions.assertAtLeastOneFlagExists([ + "flag2", + "flag3", + ]); + expect(result.isValid).toBe(false); + expect(result.errorMessage).toBe( + 'In project /path/to/project, `options.compatibilityFlags` must contain one of "flag2"/"flag3".\nEither one of these flags is required to use `@cloudflare/vitest-pool-workers`.' + ); + }); + + it("includes relativeWranglerConfigPath in error message when provided", () => { + const options = { + ...baseOptions, + compatibilityDate: "2020-01-01", + compatibilityFlags: [], + relativeWranglerConfigPath: "wrangler.toml", + }; + const flagAssertions = new CompatibilityFlagAssertions(options); + const result = flagAssertions.assertAtLeastOneFlagExists([ + "flag2", + "flag3", + ]); + expect(result.isValid).toBe(false); + expect(result.errorMessage).toBe( + 'In project /path/to/project\'s configuration file wrangler.toml, `compatibility_flags` must contain one of "flag2"/"flag3".\nEither one of these flags is required to use `@cloudflare/vitest-pool-workers`.' + ); + }); + + it("returns true when all flags are present", () => { + const options = { + ...baseOptions, + compatibilityDate: "2020-01-01", + compatibilityFlags: ["flag1", "flag2", "flag3"], + }; + const flagAssertions = new CompatibilityFlagAssertions(options); + const result = flagAssertions.assertAtLeastOneFlagExists([ + "flag1", + "flag2", + "flag3", + ]); + expect(result.isValid).toBe(true); + }); + + it("returns false when compatibilityFlags is empty", () => { + const options = { + ...baseOptions, + compatibilityDate: "2020-01-01", + compatibilityFlags: [], + }; + const flagAssertions = new CompatibilityFlagAssertions(options); + const result = flagAssertions.assertAtLeastOneFlagExists([ + "flag1", + "flag2", + ]); + expect(result.isValid).toBe(false); + expect(result.errorMessage).toBe( + 'In project /path/to/project, `options.compatibilityFlags` must contain one of "flag1"/"flag2".\nEither one of these flags is required to use `@cloudflare/vitest-pool-workers`.' + ); + }); + + it("returns true when flags array is empty", () => { + const options = { + ...baseOptions, + compatibilityDate: "2020-01-01", + compatibilityFlags: ["flag1"], + }; + const flagAssertions = new CompatibilityFlagAssertions(options); + const result = flagAssertions.assertAtLeastOneFlagExists([]); + expect(result.isValid).toBe(true); + }); + }); +}); diff --git a/packages/vitest-pool-workers/test/validation.test.ts b/packages/vitest-pool-workers/test/validation.test.ts index 78357358c23a..fb44bc787aa3 100644 --- a/packages/vitest-pool-workers/test/validation.test.ts +++ b/packages/vitest-pool-workers/test/validation.test.ts @@ -89,89 +89,6 @@ test( { timeout: 45_000 } ); -test( - "requires specific compatibility flags", - async ({ expect, seed, vitestRun, tmpPath }) => { - const tmpPathName = path.basename(tmpPath); - - // Check messages without Wrangler configuration path defined - await seed({ - "vitest.config.mts": dedent` - import { defineWorkersConfig } from "@cloudflare/vitest-pool-workers/config"; - export default defineWorkersConfig({}); - `, - "index.test.ts": "", - }); - let result = await vitestRun(); - expect(await result.exitCode).toBe(1); - let expected = dedent` - Error: In project ${path.join(tmpPathName, "vitest.config.mts")}, \`test.poolOptions.workers.miniflare.compatibilityFlags\` must contain "export_commonjs_default", or \`test.poolOptions.workers.miniflare.compatibilityDate\` must be >= "2022-10-31". - This flag is required to use \`@cloudflare/vitest-pool-workers\`. - `.replaceAll("\t", " "); - expect(result.stderr).toMatch(expected); - - await seed({ - "vitest.config.mts": dedent` - import { defineWorkersConfig } from "@cloudflare/vitest-pool-workers/config"; - export default defineWorkersConfig({ - test: { - poolOptions: { - workers: { - miniflare: { compatibilityDate: "2024-01-01" } - }, - }, - } - }); - `, - }); - result = await vitestRun(); - expect(await result.exitCode).toBe(1); - expected = dedent` - Error: In project ${path.join(tmpPathName, "vitest.config.mts")}, \`test.poolOptions.workers.miniflare.compatibilityFlags\` must contain "nodejs_compat". - This flag is required to use \`@cloudflare/vitest-pool-workers\`. - `.replaceAll("\t", " "); - expect(result.stderr).toMatch(expected); - - // Check messages with Wrangler configuration path defined - await seed({ - "vitest.config.mts": dedent` - import { defineWorkersConfig } from "@cloudflare/vitest-pool-workers/config"; - export default defineWorkersConfig({ - test: { - poolOptions: { - workers: { - wrangler: { configPath: "./wrangler.toml" } - }, - }, - } - }); - `, - "wrangler.toml": "", - }); - result = await vitestRun(); - expect(await result.exitCode).toBe(1); - expected = dedent` - Error: In project ${path.join(tmpPathName, "vitest.config.mts")}'s configuration file ${path.join(tmpPathName, "wrangler.toml")}, \`compatibility_flags\` must contain "export_commonjs_default", or \`compatibility_date\` must be >= "2022-10-31". - This flag is required to use \`@cloudflare/vitest-pool-workers\`. - `.replaceAll("\t", " "); - expect(result.stderr).toMatch(expected); - - await seed({ - "wrangler.toml": dedent` - compatibility_date = "2024-01-01" - `, - }); - result = await vitestRun(); - expect(await result.exitCode).toBe(1); - expected = dedent` - Error: In project ${path.join(tmpPathName, "vitest.config.mts")}'s configuration file ${path.join(tmpPathName, "wrangler.toml")}, \`compatibility_flags\` must contain "nodejs_compat". - This flag is required to use \`@cloudflare/vitest-pool-workers\`. - `.replaceAll("\t", " "); - expect(result.stderr).toMatch(expected); - }, - { timeout: 45_000 } -); - test( "requires modules entrypoint to use SELF", async ({ expect, seed, vitestRun, tmpPath }) => {