diff --git a/package-lock.json b/package-lock.json index ba1e49e..b13940e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2255,7 +2255,7 @@ }, "packages/openfeature-server-provider": { "name": "@mixpanel/openfeature-server-provider", - "version": "0.1.0", + "version": "0.2.0", "license": "MIT", "devDependencies": { "@openfeature/core": "^1.9.2", diff --git a/packages/mixpanel/lib/flags/custom_operators.js b/packages/mixpanel/lib/flags/custom_operators.js new file mode 100644 index 0000000..061ff82 --- /dev/null +++ b/packages/mixpanel/lib/flags/custom_operators.js @@ -0,0 +1,256 @@ +const jsonLogic = require("json-logic-js"); + +// Strict RFC3339 guard for datetime strings. The date and hour fields are captured so the calendar +// can be validated separately; the pattern only constrains their shape. +const RFC3339_PATTERN = + /^(\d{4})-(\d{2})-(\d{2})[Tt](\d{2}):\d{2}:\d{2}(\.\d+)?([Zz]|[+-]\d{2}:\d{2})$/; + +// SemVer 2.0.0 requires major.minor.patch; partial versions are zero-padded to this. +const SEMVER_PARTS = 3; + +// Longest operand the semver regex is allowed to see. A real version never approaches this; the +// bound matches MAX_LENGTH in node-semver, and keeps an arbitrarily long property value off the +// regex regardless of how the engine schedules backtracking. +const MAX_SEMVER_LENGTH = 256; + +// Epoch milliseconds are compared as int64 elsewhere, so anything at or beyond this is out of range. +const MAX_EPOCH_MS = 9223372036854775808; + +// Using the official semantic versioning 2.0.0 regular expression to handle cross-platform validation +// differences on other SDK's. For example, some platforms allow leading zeros even though it is not valid +// as part of the Semver 2.0.0 spec. See https://semver.org/ +const SEMVER_PATTERN = + /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$/; + +jsonLogic.add_operation("semver_compare", semverCompare); +jsonLogic.add_operation("datetime_compare", datetimeCompare); + +// Implements a custom operation for semantic versioning comparison that conforms to the semver 2.0.0 standard. +// Prior to comparison, any leading version prefix is stripped. +function semverCompare(actual, symbol, target) { + if (arguments.length !== 3) { + return false; + } + if (typeof actual !== "string" || typeof target !== "string") { + return false; + } + if (actual.length > MAX_SEMVER_LENGTH || target.length > MAX_SEMVER_LENGTH) { + return false; + } + const actualVersion = normalizeSemver(actual); + const targetVersion = normalizeSemver(target); + if ( + !SEMVER_PATTERN.test(actualVersion) || + !SEMVER_PATTERN.test(targetVersion) + ) { + return false; + } + const cmp = compareSemver(actualVersion, targetVersion); + return comparatorMatches(cmp, symbol); +} + +// Strip optional build metadata and separate the core version from pre-release identifiers +function splitSemver(version) { + const plus = version.indexOf("+"); + if (plus !== -1) { + version = version.slice(0, plus); + } + const dash = version.indexOf("-"); + if (dash === -1) { + return { core: version.split("."), prerelease: [] }; + } + return { + core: version.slice(0, dash).split("."), + prerelease: version.slice(dash + 1).split("."), + }; +} + +function isNumericIdentifier(identifier) { + return /^[0-9]+$/.test(identifier); +} + +// Numeric identifiers carry no leading zeros, so the longer run of digits is the larger number. +// Comparing them as digits rather than as numbers keeps versions past Number.MAX_SAFE_INTEGER +// ordered correctly. +function compareNumeric(a, b) { + if (a.length !== b.length) { + return a.length < b.length ? -1 : 1; + } + return a < b ? -1 : a > b ? 1 : 0; +} + +// SemVer 2.0.0 section 11.4: digits compare numerically, a numeric identifier ranks below an +// alphanumeric one, and anything else compares by ASCII order. +function comparePrereleaseIdentifier(a, b) { + const aNumeric = isNumericIdentifier(a); + const bNumeric = isNumericIdentifier(b); + if (aNumeric && bNumeric) { + return compareNumeric(a, b); + } + if (aNumeric) { + return -1; + } + if (bNumeric) { + return 1; + } + return a < b ? -1 : a > b ? 1 : 0; +} + +// Ordering per SemVer 2.0.0 section 11. Both operands have already been normalized and matched +// against the official regex, so the core holds exactly three numeric identifiers and every +// prerelease field is well-formed; the split needs no error path. +function compareSemver(actualVersion, targetVersion) { + const actual = splitSemver(actualVersion); + const target = splitSemver(targetVersion); + + for (let i = 0; i < actual.core.length; i++) { + const result = compareNumeric(actual.core[i], target.core[i]); + if (result !== 0) { + return result; + } + } + + // A prerelease ranks below the release it belongs to (section 11.3). + if (!actual.prerelease.length && !target.prerelease.length) { + return 0; + } + if (!actual.prerelease.length) { + return 1; + } + if (!target.prerelease.length) { + return -1; + } + + const shared = Math.min(actual.prerelease.length, target.prerelease.length); + for (let i = 0; i < shared; i++) { + const result = comparePrereleaseIdentifier( + actual.prerelease[i], + target.prerelease[i], + ); + if (result !== 0) { + return result; + } + } + // Every field so far is equal, so the longer list wins (section 11.4.4). + if (actual.prerelease.length !== target.prerelease.length) { + return actual.prerelease.length < target.prerelease.length ? -1 : 1; + } + return 0; +} + +// Implements a custom operation for datetime comparison. +// The target value stored on the feature flag is the millisecond epoch, whereas the actual value provided at evaluation time must be RFC-3339 formatted. +function datetimeCompare(actual, symbol, target) { + if (arguments.length !== 3) { + return false; + } + const actualSec = convertRfc3339ToUnixSeconds(actual); + const targetSec = convertUnixMillisecondsToSeconds(target); + if (actualSec === null || targetSec === null) { + return false; + } + const cmp = actualSec - targetSec; + return comparatorMatches(cmp, symbol); +} + +function comparatorMatches(cmp, symbol) { + switch (symbol) { + case "===": + return cmp === 0; + case "!==": + return cmp !== 0; + case "<": + return cmp < 0; + case "<=": + return cmp <= 0; + case ">": + return cmp > 0; + case ">=": + return cmp >= 0; + default: + return false; + } +} + +function normalizeSemver(version) { + const stripped = version.trim().replace(/^[vV]/, ""); + + let suffixStart = stripped.length; + for (const separator of ["-", "+"]) { + const index = stripped.indexOf(separator); + if (index !== -1 && index < suffixStart) { + suffixStart = index; + } + } + + const core = stripped.slice(0, suffixStart); + const suffix = stripped.slice(suffixStart); + + const parts = core.split("."); + while (parts.length < SEMVER_PARTS) { + parts.push("0"); + } + return parts.join(".") + suffix; +} + +// The pattern constrains each field to two digits, which still admits a date that cannot exist, such +// as 2026-02-30 or 29 February in a common year. Writing the fields into a Date and reading them back +// settles it: out-of-range fields are normalized into a real instant, so a date that does not exist +// comes back carrying different fields than it went in with. The three-argument setUTCFullYear sets +// all three at once, which judges 29 February against the year given rather than a placeholder, and +// leaves years 0 through 99 alone where Date.UTC would map them into the 1900s. The hour is checked +// separately because it is not part of the round trip; RFC 3339 section 5.6 allows hours 00 through 23. +function isRealCalendarDate(year, month, day, hour) { + if (hour > 23) { + return false; + } + const dt = new Date(); + dt.setUTCFullYear(year, month - 1, day); + return ( + dt.getUTCFullYear() === year && + dt.getUTCMonth() === month - 1 && + dt.getUTCDate() === day + ); +} + +function convertRfc3339ToUnixSeconds(value) { + if (typeof value !== "string") { + return null; + } + const normalized = value.trim().toUpperCase(); + const fields = RFC3339_PATTERN.exec(normalized); + if (!fields) { + return null; + } + const [, year, month, day, hour] = fields; + if ( + !isRealCalendarDate(Number(year), Number(month), Number(day), Number(hour)) + ) { + return null; + } + const ms = Date.parse(normalized); + if (Number.isNaN(ms)) { + return null; + } + return Math.floor(ms / 1000); +} + +function convertUnixMillisecondsToSeconds(value) { + if (typeof value !== "number" || !Number.isFinite(value)) { + return null; + } + // A value int64 cannot represent is not a real timestamp; treating one as a bound would let a + // nonsense target define a rollout window. + if (value >= MAX_EPOCH_MS || value <= -MAX_EPOCH_MS) { + return null; + } + return Math.trunc(value / 1000); +} + +module.exports = { + comparatorMatches, + semverCompare, + convertRfc3339ToUnixSeconds, + convertUnixMillisecondsToSeconds, + datetimeCompare, +}; diff --git a/packages/mixpanel/lib/flags/local_flags.js b/packages/mixpanel/lib/flags/local_flags.js index fc834ef..15bd81d 100644 --- a/packages/mixpanel/lib/flags/local_flags.js +++ b/packages/mixpanel/lib/flags/local_flags.js @@ -24,6 +24,7 @@ const { asFallback, } = require("./variant_source"); const { apply } = require("json-logic-js"); +require("./custom_operators"); class LocalFeatureFlagsProvider extends FeatureFlagsProvider { /** diff --git a/packages/mixpanel/test/flags/custom_operators.js b/packages/mixpanel/test/flags/custom_operators.js new file mode 100644 index 0000000..bdff362 --- /dev/null +++ b/packages/mixpanel/test/flags/custom_operators.js @@ -0,0 +1,74 @@ +const fs = require("fs"); +const path = require("path"); + +const { apply } = require("json-logic-js"); +// Requiring the module registers semver_compare and datetime_compare on the shared +// json-logic-js instance used by apply(). +require("../../lib/flags/custom_operators"); + +// The golden vectors are the cross-SDK contract for the custom operators; the canonical copy and +// its README live in the analytics monorepo. Cases run through apply() so that operator +// registration is covered alongside the comparison itself. +const TEST_DATA = path.join(__dirname, "test-data"); + +// The property key the vectors are evaluated against. It is plumbing the test supplies, so any name +// works as long as the rule and the data agree on it. +const VECTOR_KEY = "value"; + +// Build the event the rule reads from, omitting the key entirely for an unset property. +function dataFor(subject) { + return subject === null ? {} : { [VECTOR_KEY]: subject }; +} + +// Read a golden-vector file. String entries are headings, array entries are cases. +function loadVectors(operator) { + const entries = JSON.parse( + fs.readFileSync( + path.join(TEST_DATA, `${operator}_compare_tests.json`), + "utf8", + ), + ); + + let section = ""; + const cases = []; + entries.forEach((entry, index) => { + if (typeof entry === "string") { + section = entry; + return; + } + const [subject, symbol, target, want] = entry; + const rule = { + [`${operator}_compare`]: [{ var: VECTOR_KEY }, symbol, target], + }; + const name = `${index} ${section}: ${JSON.stringify(subject)} ${symbol} ${JSON.stringify(target)}`; + cases.push([name, rule, dataFor(subject), want]); + }); + return cases; +} + +describe("semver_compare operator", () => { + it.each(loadVectors("semver"))("%s", (_name, rule, data, want) => { + expect(apply(rule, data)).toBe(want); + }); +}); + +describe("datetime_compare operator", () => { + it.each(loadVectors("datetime"))("%s", (_name, rule, data, want) => { + expect(apply(rule, data)).toBe(want); + }); +}); + +// The cases below are not golden vectors. They pin behaviour the shared files cannot express: a +// rule shape the engine would never produce, and the difference between an absent property and one +// holding a null, which both fail closed. +describe("fail-closed guards", () => { + it("refuses a rule that is missing an operand", () => { + const rule = { datetime_compare: [{ var: "signup" }, "==="] }; + expect(apply(rule, { signup: "2026-07-16T00:00:00Z" })).toBe(false); + }); + + it("omits the property for an unset subject", () => { + expect(dataFor(null)).toEqual({}); + expect(dataFor("1.2.3")).toEqual({ [VECTOR_KEY]: "1.2.3" }); + }); +}); diff --git a/packages/mixpanel/test/flags/local_flags.js b/packages/mixpanel/test/flags/local_flags.js index 312f6fe..2d122af 100644 --- a/packages/mixpanel/test/flags/local_flags.js +++ b/packages/mixpanel/test/flags/local_flags.js @@ -517,6 +517,62 @@ describe("LocalFeatureFlagsProvider", () => { expect(result.variant_value).toBe(FALLBACK_NAME); }); + it("should return variant when semver_compare runtime rule satisfied", async () => { + const runtimeEvaluationRule = { + semver_compare: [{ var: "app_version" }, ">=", "1.2.3"], + }; + await createFlagAndLoadItIntoSDK({ runtimeEvaluationRule }, provider); + + const context = userContextWithRuntimeParameters({ + app_version: "1.5.0", + }); + + const result = provider.getVariant(FLAG_KEY, FALLBACK, context); + assertVariantReturned(result); + }); + + it("should return fallback when semver_compare runtime rule not satisfied", async () => { + const runtimeEvaluationRule = { + semver_compare: [{ var: "app_version" }, ">=", "1.2.3"], + }; + await createFlagAndLoadItIntoSDK({ runtimeEvaluationRule }, provider); + + const context = userContextWithRuntimeParameters({ + app_version: "1.0.0", + }); + + const result = provider.getVariant(FLAG_KEY, FALLBACK, context); + expect(result.variant_value).toBe(FALLBACK_NAME); + }); + + it("should return variant when datetime_compare runtime rule satisfied", async () => { + const runtimeEvaluationRule = { + datetime_compare: [{ var: "signup" }, "<", 1_784_160_000_000], + }; + await createFlagAndLoadItIntoSDK({ runtimeEvaluationRule }, provider); + + const context = userContextWithRuntimeParameters({ + signup: "2026-07-15T00:00:00Z", + }); + + const result = provider.getVariant(FLAG_KEY, FALLBACK, context); + assertVariantReturned(result); + }); + + it("should return fallback when datetime_compare runtime rule not satisfied", async () => { + const runtimeEvaluationRule = { + datetime_compare: [{ var: "signup" }, "<", 1_784_160_000_000], + }; + await createFlagAndLoadItIntoSDK({ runtimeEvaluationRule }, provider); + + const context = userContextWithRuntimeParameters({ + signup: "2026-07-17T00:00:00Z", + }); + + const result = provider.getVariant(FLAG_KEY, FALLBACK, context); + expect(result.variant_value).toBe(FALLBACK_NAME); + }); + it("should respect legacy runtime evaluation when satisfied", async () => { const legacyRuntimeRule = { plan: "premium", region: "US" }; await createFlagAndLoadItIntoSDK( diff --git a/packages/mixpanel/test/flags/test-data/datetime_compare_tests.json b/packages/mixpanel/test/flags/test-data/datetime_compare_tests.json new file mode 100644 index 0000000..5e8633d --- /dev/null +++ b/packages/mixpanel/test/flags/test-data/datetime_compare_tests.json @@ -0,0 +1,100 @@ +[ + "A list of golden vectors for custom operators, to ensure logic parity across platforms", + + "# Ordering and the six symbols", + ["2026-07-15T00:00:00Z", "<", 1784160000000, true], + ["2026-07-16T00:00:00Z", "<", 1784160000000, false], + ["2026-07-16T00:00:00Z", "===", 1784160000000, true], + ["2026-07-17T00:00:00Z", "!==", 1784160000000, true], + ["2026-07-16T00:00:00Z", ">=", 1784160000000, true], + ["2026-07-17T00:00:00Z", ">", 1784160000000, true], + ["2026-07-15T00:00:00Z", ">", 1784160000000, false], + ["2026-07-16T00:00:00Z", "<=", 1784160000000, true], + ["2026-07-17T00:00:00Z", "<=", 1784160000000, false], + ["2026-07-17T00:00:00Z", "===", 1784160000000, false], + ["2026-07-16T00:00:00Z", "!==", 1784160000000, false], + ["2026-07-15T00:00:00Z", ">=", 1784160000000, false], + + "# Leap day", + ["2024-02-29T00:00:00Z", "===", 1709164800000, true], + + "# Time-zone offsets change the instant", + ["2026-07-16T00:00:00+05:30", "===", 1784140200000, true], + ["2026-07-16T02:00:00+02:00", "===", 1784160000000, true], + ["2026-07-16T00:00:00+05:30", "<", 1784160000000, true], + ["2026-07-16T00:00:00-08:00", "===", 1784188800000, true], + ["2026-07-16T00:00:00-08:00", ">", 1784160000000, true], + ["2026-07-16T00:00:00+00:00", "===", 1784160000000, true], + + "# Sub-second precision is dropped", + ["2026-07-16T00:00:00.5Z", "===", 1784160000000, true], + ["2026-07-16T00:00:00.500Z", "===", 1784160000000, true], + ["2026-07-16T00:00:00.123456Z", "===", 1784160000000, true], + ["2026-07-16T00:00:00.999999999Z", "===", 1784160000000, true], + ["2026-07-16T00:00:00.0Z", "===", 1784160000000, true], + ["2026-07-16T00:00:00.500Z", ">=", 1784160000000, true], + ["2026-07-16T23:59:59Z", "===", 1784246399000, true], + ["2026-07-16T23:59:59Z", "<=", 1784246399000, true], + ["2026-07-16T23:59:59.999Z", "===", 1784246399000, true], + ["2026-07-16T23:59:59.999Z", "<=", 1784246399000, true], + + "# Trimming and lowercasing", + ["2026-07-16t00:00:00.500z", "===", 1784160000000, true], + ["2026-07-16t02:00:00+02:00", "===", 1784160000000, true], + [" 2026-07-16T00:00:00Z ", "===", 1784160000000, true], + ["2026-07-16t00:00:00z", "===", 1784160000000, true], + + "# Wrong shapes, checked under both symbols", + ["2026-7-16T00:00:00Z", "===", 1784160000000, false], + ["2026-7-16T00:00:00Z", "!==", 1784160000000, false], + ["2026-07-16 00:00:00Z", "===", 1784160000000, false], + ["2026-07-16 00:00:00Z", "!==", 1784160000000, false], + ["2026-07-16T00:00:00", "===", 1784160000000, false], + ["2026-07-16T00:00:00", "!==", 1784160000000, false], + ["2026-07-16T00:00:00.Z", "===", 1784160000000, false], + ["2026-07-16T00:00:00.Z", "!==", 1784160000000, false], + ["2026-07-16T00:00:00+0200", "===", 1784160000000, false], + ["2026-07-16T00:00:00+0200", "!==", 1784160000000, false], + ["2026-07-16T00:00:00+02", "===", 1784160000000, false], + ["2026-07-16T00:00:00+02", "!==", 1784160000000, false], + ["2026-07-16T00:00:00Zextra", "===", 1784160000000, false], + ["2026-07-16T00:00:00Zextra", "!==", 1784160000000, false], + ["2026-07-16", "===", 1784160000000, false], + ["2026-07-16", "!==", 1784160000000, false], + ["20260716T000000Z", "===", 1784160000000, false], + ["20260716T000000Z", "!==", 1784160000000, false], + ["2026-07-16T00:00:00z00:00", "===", 1784160000000, false], + ["2026-07-16T00:00:00z00:00", "!==", 1784160000000, false], + ["2026-07-16T00:00:00,5Z", "===", 1784160000000, false], + ["2026-07-16T00:00:00,5Z", "!==", 1784160000000, false], + + "# Missing or wrong-typed values", + [1784160000000, "===", 1784160000000, false], + ["2026-07-16T00:00:00Z", "===", 1e19, false], + ["2026-07-16T00:00:00Z", ">", 1e19, false], + ["2026-07-16T00:00:00Z", "<", 1e19, false], + ["2026-07-16", "===", 1784160000000, false], + ["2026-07-16T00:00:00", "===", 1784160000000, false], + ["yesterday", "===", 1784160000000, false], + [null, "===", 1784160000000, false], + + "# Targets before 1970", + ["1969-12-31T23:59:59Z", "===", -1000, true], + ["1969-12-31T23:59:59Z", "!==", -1000, false], + ["1969-12-31T23:59:59Z", ">=", -1000, true], + ["1969-12-31T23:59:58Z", "<", -1000, true], + ["1969-12-31T23:59:59Z", ">", -2000, true], + ["1969-12-31T23:59:58.500Z", "===", -2000, true], + ["1969-12-31T23:59:58.500Z", "!==", -1000, true], + + "# Impossible dates and out-of-range fields", + ["2026-02-30T00:00:00Z", "===", 1784160000000, false], + ["2026-02-30T00:00:00Z", "!==", 1784160000000, false], + ["2026-02-29T00:00:00Z", "!==", 1784160000000, false], + ["2025-02-29T00:00:00Z", "!==", 1784160000000, false], + ["2026-04-31T00:00:00Z", "!==", 1784160000000, false], + ["2026-06-31T00:00:00Z", "!==", 1784160000000, false], + ["2026-07-16T24:00:00Z", "!==", 1784160000000, false], + ["2026-13-01T00:00:00Z", "!==", 1784160000000, false], + ["2026-01-32T00:00:00Z", "!==", 1784160000000, false] +] diff --git a/packages/mixpanel/test/flags/test-data/semver_compare_tests.json b/packages/mixpanel/test/flags/test-data/semver_compare_tests.json new file mode 100644 index 0000000..ae475e6 --- /dev/null +++ b/packages/mixpanel/test/flags/test-data/semver_compare_tests.json @@ -0,0 +1,181 @@ +[ + "A list of golden vectors for custom operators, to ensure logic parity across platforms", + + "# Ordering and the six symbols", + ["1.2.3", "===", "1.2.3", true], + ["1.2.4", "===", "1.2.3", false], + ["1.2.4", "!==", "1.2.3", true], + ["1.2.2", "<", "1.2.3", true], + ["1.2.3", "<", "1.2.3", false], + ["1.2.3", "<=", "1.2.3", true], + ["1.3.0", ">", "1.2.3", true], + ["1.2.3", ">=", "1.2.3", true], + ["1.10.0", ">", "1.9.0", true], + ["10.0.0", ">", "9.0.0", true], + ["1.0.10", ">", "1.0.9", true], + ["2.0.0", ">", "1.9.9", true], + ["1.0.0-alpha", "<", "1.0.0", true], + ["v1.2.3", "===", "1.2.3", true], + ["1.2.0", "===", "1.2", true], + [" 1.2.3 ", "===", "1.2.3", true], + ["1.2.3", "!==", "1.2.3", false], + ["1.2.4", "<=", "1.2.3", false], + ["1.2.2", ">", "1.2.3", false], + ["1.2.2", ">=", "1.2.3", false], + + "# Pre-release ordering", + ["1.0.0-alpha", "<", "1.0.0-beta", true], + ["1.0.0-beta", "<", "1.0.0-rc1", true], + ["1.0.0-rc1", "<", "1.0.0-rc2", true], + ["1.0.0-alpha", "<", "1.0.0-alpha.1", true], + ["1.0.0-alpha.1", "<", "1.0.0-alpha.beta", true], + ["1.0.0-alpha", "<", "1.0.0-alpha.beta", true], + ["1.0.0-beta.2", "<", "1.0.0-beta.11", true], + ["1.0.0-a.1", "<", "1.0.0-b.1", true], + ["1.0.0-a.1", "<", "1.0.0-a.2", true], + ["1.0.0-rc1", "===", "1.0.0-rc1", true], + ["1.0.0-rc1", ">", "1.0.0-rc.1", true], + ["2.0.0-alpha", ">", "1.9.9", true], + + "# A pre-release and a plain release, compared directly", + ["1.0.0", ">", "1.0.0-alpha", true], + ["1.0.0", ">=", "1.0.0-rc1", true], + ["1.0.0", "!==", "1.0.0-alpha", true], + ["1.0.0-alpha", "!==", "1.0.0", true], + ["1.0.0-alpha", "<=", "1.0.0", true], + ["1.0.0-alpha", ">", "0.9.9", true], + ["1.0.0-rc1", "<", "1.0.1", true], + + "# How pre-release identifiers compare, SemVer 2.0.0 item 11", + ["1.0.0-2", "<", "1.0.0-10", true], + ["1.0.0-1", "<", "1.0.0-alpha", true], + ["1.0.0-alpha", "<", "1.0.0-alpha-1", true], + ["1.0.0-beta.11", "<", "1.0.0-rc.1", true], + ["1.0.0-rc.1", "<", "1.0.0", true], + ["1.0.0-alpha.1.2.3", "<", "1.0.0-beta", true], + ["1.0.0-beta", ">", "1.0.0-alpha.1", true], + + "# Build metadata is ignored", + ["1.0.0+build1", "===", "1.0.0+build2", true], + ["1.0.0-alpha+build", "===", "1.0.0-alpha", true], + ["1.2.3+build.1-2", "===", "1.2.3", true], + ["1.0.0+build1", "!==", "1.0.0+build2", false], + ["1.0.0+build1", "<", "1.0.0+build2", false], + ["1.0.0+build1", ">", "1.0.0+build2", false], + ["1.0.0+build1", "<=", "1.0.0+build2", true], + ["1.0.0+build1", ">=", "1.0.0+build2", true], + ["1.0.0+build9", "<", "1.0.1+build1", true], + ["1.0.1+build1", ">", "1.0.0+build9", true], + + "# Partial versions", + ["1.2-alpha", "===", "1.2.0-alpha", true], + ["1.2-alpha", "<", "1.3.1", true], + ["1.2-alpha", "<", "1.2.0", true], + ["1-rc1", "<", "1.0.0", true], + ["1.2+build", "===", "1.2.0", true], + + "# Zero versions", + ["0.0.0", "===", "0.0.0", true], + ["0.0.0", "<", "0.0.1", true], + ["0", "===", "0.0.0", true], + + "# A version ending in a bare hyphen is rejected", + ["1.0.0-", "===", "1.0.0", false], + ["1.0.0-", "!==", "1.0.0", false], + ["1.2-", "===", "1.2.0", false], + ["1.2-", "!==", "1.2.0", false], + + "# Hyphens inside a pre-release are fine, since they are part of the pre-release identifier", + ["1.0.0-alpha-", "<", "1.0.0", true], + + "# Leading zeros are rejected", + ["01.2.3", "===", "1.2.3", false], + ["01.2.3", "!==", "1.2.3", false], + ["1.02.3", "===", "1.2.3", false], + ["1.02.3", "!==", "1.2.3", false], + ["1.2.03", "===", "1.2.3", false], + ["1.2.03", "!==", "1.2.3", false], + ["01.02.03", "===", "1.2.3", false], + ["01.02.03", "!==", "1.2.3", false], + + "# Leading zeros in a numeric pre-release are rejected too, SemVer 2.0.0 item 9", + ["1.2.3-01", "===", "1.2.3", false], + ["1.2.3-01", "!==", "1.2.3", false], + ["1.2.3-rc.01", "===", "1.2.3", false], + ["1.2.3-rc.01", "!==", "1.2.3", false], + + "# A lone zero is a legal pre-release identifier", + ["1.2.3-0", "<", "1.2.3", true], + + "# Digits inside a word are still valid", + ["1.2.3-rc01", "<", "1.2.3", true], + + "# A leading v is accepted, in either case", + ["V1.2.3", "===", "1.2.3", true], + ["v1.0.0-alpha", "<", "1.0.0", true], + ["v1.2.4", "!==", "1.2.3", true], + ["v1.2.3", "<=", "1.2.3", true], + ["v1.2.4", ">", "1.2.3", true], + ["v1.2.3", ">=", "1.2.3", true], + + "# Missing or wrong-typed values", + ["not-a-version", "===", "1.2.3", false], + [123, "===", "1.2.3", false], + [null, "===", "1.2.3", false], + + "# Malformed versions, checked under both symbols", + ["", "===", "1.2.3", false], + ["", "!==", "1.2.3", false], + ["v", "===", "1.2.3", false], + ["v", "!==", "1.2.3", false], + ["-1.2.3", "===", "1.2.3", false], + ["-1.2.3", "!==", "1.2.3", false], + ["1.", "===", "1.2.3", false], + ["1.", "!==", "1.2.3", false], + ["1.2.3.", "===", "1.2.3", false], + ["1.2.3.", "!==", "1.2.3", false], + ["1..2", "===", "1.2.3", false], + ["1..2", "!==", "1.2.3", false], + ["1.2.3.4", "===", "1.2.3", false], + ["1.2.3.4", "!==", "1.2.3", false], + ["^1.2.3", "===", "1.2.3", false], + ["^1.2.3", "!==", "1.2.3", false], + ["abc1.2.3", "===", "1.2.3", false], + ["abc1.2.3", "!==", "1.2.3", false], + ["1.2.3+", "===", "1.2.3", false], + ["1.2.3+", "!==", "1.2.3", false], + ["1.2.3-alpha..1", "===", "1.2.3", false], + ["1.2.3-alpha..1", "!==", "1.2.3", false], + ["1.2.3-.", "===", "1.2.3", false], + ["1.2.3-.", "!==", "1.2.3", false], + ["1.2.3-ALPHA_BETA", "===", "1.2.3", false], + ["1.2.3-ALPHA_BETA", "!==", "1.2.3", false], + ["vv1.2.3", "===", "1.2.3", false], + ["vv1.2.3", "!==", "1.2.3", false], + + "# Operand length cap", + [ + "1.2.3-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "===", + "1.2.3", + false + ], + [ + "1.2.3-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "!==", + "1.2.3", + false + ], + [ + "1.2.3", + "!==", + "1.2.3-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + false + ], + [ + "1.2.3-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "!==", + "1.2.3", + true + ] +]