diff --git a/packages/compute-baseline/src/browser-compat-data/release.test.ts b/packages/compute-baseline/src/browser-compat-data/release.test.ts index 73b7b5146e8..bd6860c647c 100644 --- a/packages/compute-baseline/src/browser-compat-data/release.test.ts +++ b/packages/compute-baseline/src/browser-compat-data/release.test.ts @@ -40,6 +40,16 @@ describe("Release", function () { }); describe("compare()", function () { + it("throws when comparing between two browsers", function () { + const cr = browser("chrome"); + const ed = browser("edge"); + + assert.throws( + () => cr.version("100").inRange(ed.version("79"), cr.version("125")), + Error, + ); + }); + it("returns 0 for equivalent releases", function () { const chrome100 = browser("chrome").version("100"); assert.equal(chrome100.compare(chrome100), 0); @@ -87,4 +97,46 @@ describe("Release", function () { assert.equal(safariPreview.isPrerelease(), true); }); }); + + describe("inRange()", function () { + it("throws when comparing between two browsers", function () { + const cr = browser("chrome"); + const fx = browser("firefox"); + + assert.throws(() => cr.version("50").inRange(fx.version("50")), Error); + }); + + it("handles closed ranges", function () { + const cr = browser("chrome"); + + // Start of range is inclusive + assert.equal( + cr.version("1").inRange(cr.version("1"), cr.version("125")), + true, + ); + + // End of range is exclusive + assert.equal( + cr.version("20").inRange(cr.version("1"), cr.version("20")), + false, + ); + + assert.equal( + cr.version("1").inRange(cr.version("10"), cr.version("15")), + false, + ); + assert.equal( + cr.version("100").inRange(cr.version("10"), cr.version("15")), + false, + ); + }); + + it("handles open ranges", function () { + const cr = browser("chrome"); + assert.equal(cr.version("1").inRange(cr.version("1")), true); + assert.equal(cr.version("1").inRange(cr.version("10")), false); + assert.equal(cr.version("100").inRange(cr.version("10")), true); + assert.equal(cr.version("preview").inRange(cr.version("10")), true); + }); + }); }); diff --git a/packages/compute-baseline/src/browser-compat-data/release.ts b/packages/compute-baseline/src/browser-compat-data/release.ts index 3db2140e008..46636597476 100644 --- a/packages/compute-baseline/src/browser-compat-data/release.ts +++ b/packages/compute-baseline/src/browser-compat-data/release.ts @@ -44,6 +44,19 @@ export class Release { return this.releaseIndex - otherRelease.releaseIndex; } + /** + * Check if this release is the same as or after a starting release and, + * optionally, before an ending release. + */ + inRange(start: Release, end?: Release): boolean { + const onOrAfterStart = this.compare(start) >= 0; + if (end) { + const beforeEnd = this.compare(end) < 0; + return onOrAfterStart && beforeEnd; + } + return onOrAfterStart; + } + isPrerelease(): boolean { if (["beta", "nightly", "planned"].includes(this.data.status)) { return true; diff --git a/packages/compute-baseline/src/browser-compat-data/supportStatements.test.ts b/packages/compute-baseline/src/browser-compat-data/supportStatements.test.ts index e315e873489..fb7d0e21bf6 100644 --- a/packages/compute-baseline/src/browser-compat-data/supportStatements.test.ts +++ b/packages/compute-baseline/src/browser-compat-data/supportStatements.test.ts @@ -96,10 +96,18 @@ describe("statements", function () { assert.equal(s.version_removed, "2"); }); - it("returns false for undefined", function () { - const s = new SupportStatement({ version_added: "1" }); + it("returns false", function () { + const s = new SupportStatement({ + version_added: "1", + version_removed: false, + }); assert.equal(s.version_removed, false); }); + + it("returns undefined", function () { + const s = new SupportStatement({ version_added: "1" }); + assert.equal(s.version_removed, undefined); + }); }); }); @@ -163,6 +171,7 @@ describe("statements", function () { ); }); }); + describe("#supportedBy", function () { it("returns an array of releases represented by the statement", function () { const st = new RealSupportStatement( @@ -186,5 +195,148 @@ describe("statements", function () { assert.equal(rels.length, browser("chrome").releases.length - 10); }); }); + + describe("supportedIn()", function () { + it("throws when browser is undefined", function () { + const cr = browser("chrome"); + const statement = new RealSupportStatement({ version_added: "1" }); + assert.throws(() => statement.supportedIn(cr.current()), Error); + }); + + it("throws when release does not correspond to the statement's browser", function () { + const statement = new RealSupportStatement( + { version_added: "1" }, + browser("chrome"), + ); + assert.throws( + () => statement.supportedIn(browser("firefox").current()), + Error, + ); + }); + + it("returns supported when release is on after version_added", function () { + const cr = browser("chrome"); + const unranged = new RealSupportStatement({ version_added: "100" }, cr); + const ranged = new RealSupportStatement({ version_added: "≤100" }, cr); + + assert.equal(unranged.supportedIn(cr.version("100")).supported, true); + assert.equal(unranged.supportedIn(cr.version("101")).supported, true); + assert.equal(unranged.supportedIn(cr.current()).supported, true); + assert.equal( + unranged.supportedIn(cr.releases.at(-1) as any).supported, + true, + ); + + assert.equal(ranged.supportedIn(cr.version("99")).supported, null); + assert.equal(ranged.supportedIn(cr.version("100")).supported, true); + assert.equal(ranged.supportedIn(cr.version("101")).supported, true); + assert.equal(ranged.supportedIn(cr.current()).supported, true); + assert.equal( + ranged.supportedIn(cr.releases.at(-1) as any).supported, + true, + ); + }); + + it("returns supported when release is on after version_added and before version_removed", function () { + const cr = browser("chrome"); + const unranged = new RealSupportStatement( + { version_added: "100", version_removed: "125" }, + cr, + ); + const ranged = new RealSupportStatement( + { version_added: "≤100", version_removed: "125" }, + cr, + ); + + assert.equal(unranged.supportedIn(cr.version("99")).supported, false); + assert.equal(unranged.supportedIn(cr.version("100")).supported, true); + assert.equal(unranged.supportedIn(cr.version("101")).supported, true); + assert.equal(unranged.supportedIn(cr.version("124")).supported, true); + assert.equal(unranged.supportedIn(cr.version("125")).supported, false); + + assert.equal(ranged.supportedIn(cr.version("99")).supported, null); + assert.equal(ranged.supportedIn(cr.version("100")).supported, true); + assert.equal(ranged.supportedIn(cr.version("101")).supported, true); + assert.equal(ranged.supportedIn(cr.version("124")).supported, true); + assert.equal(unranged.supportedIn(cr.version("125")).supported, false); + }); + + it("returns unknown support when release is before ranged version_added", function () { + const cr = browser("chrome"); + const rangedOpen = new RealSupportStatement( + { version_added: "≤100" }, + cr, + ); + const rangedClosed = new RealSupportStatement( + { version_added: "≤100", version_removed: "125" }, + cr, + ); + + assert.equal(rangedOpen.supportedIn(cr.version("99")).supported, null); + assert.equal( + rangedClosed.supportedIn(cr.version("99")).supported, + null, + ); + }); + + it("returns unknown support when release is after version_added and before ranged version_removed", function () { + const cr = browser("chrome"); + const rangedEnd = new RealSupportStatement( + { version_added: "100", version_removed: "≤125" }, + cr, + ); + + assert.equal(rangedEnd.supportedIn(cr.version("100")).supported, true); + assert.equal(rangedEnd.supportedIn(cr.version("101")).supported, null); + assert.equal(rangedEnd.supportedIn(cr.version("124")).supported, null); + assert.equal(rangedEnd.supportedIn(cr.version("125")).supported, false); + }); + + it("returns unsupported when statement is version_added false", function () { + const cr = browser("chrome"); + const statement = new RealSupportStatement( + { version_added: false }, + cr, + ); + + for (const release of cr.releases) { + assert.equal(statement.supportedIn(release).supported, false); + } + }); + + it("returns unsupported when release is before fixed version_added", function () { + const cr = browser("chrome"); + const unranged = new RealSupportStatement({ version_added: "100" }, cr); + assert.equal(unranged.supportedIn(cr.version("99")).supported, false); + }); + + it("returns unsupported when release is on or after version_removed", function () { + const cr = browser("chrome"); + + const unranged = new RealSupportStatement( + { version_added: "1", version_removed: "10" }, + cr, + ); + assert.equal(unranged.supportedIn(cr.version("10")).supported, false); + assert.equal(unranged.supportedIn(cr.version("11")).supported, false); + assert.equal(unranged.supportedIn(cr.current()).supported, false); + assert.equal( + unranged.supportedIn(cr.releases.at(-1) as any).supported, + false, + ); + + const ranged = new RealSupportStatement( + { version_added: "≤5", version_removed: "10" }, + cr, + ); + assert.equal(ranged.supportedIn(cr.version("10")).supported, false); + assert.equal(ranged.supportedIn(cr.version("11")).supported, false); + assert.equal(ranged.supportedIn(cr.current()).supported, false); + assert.equal( + ranged.supportedIn(cr.releases.at(-1) as any).supported, + false, + ); + }); + }); }); }); diff --git a/packages/compute-baseline/src/browser-compat-data/supportStatements.ts b/packages/compute-baseline/src/browser-compat-data/supportStatements.ts index 4168096e868..244255e1eea 100644 --- a/packages/compute-baseline/src/browser-compat-data/supportStatements.ts +++ b/packages/compute-baseline/src/browser-compat-data/supportStatements.ts @@ -15,11 +15,9 @@ export interface Qualifications { partial_implementation?: true; } -// TODO: This stuff is slow, clunky, and weirdly indirect. It was helpful to get -// started (especially before I knew all of the variations that a given -// statement could express), but we might be better served by extracting -// `supportedBy()` into something like an `expandToReleases(browser: Browser, -// statement: SimpleSupportStatement)` function or similar. +export type Supported = { supported: true; qualifications?: Qualifications }; +export type Unsupported = { supported: false }; +export type UnknownSupport = { supported: null }; export function statement( incoming: @@ -68,26 +66,6 @@ export class SupportStatement { this.feature = feature; } - _isRanged(key: "version_added" | "version_removed" | undefined): boolean { - if (key === undefined) { - return ( - this._isRanged("version_added") || this._isRanged("version_removed") - ); - } - - const version = this.data?.[key]; - - if ( - typeof version === "boolean" || - version === undefined || - version === null - ) { - return false; - } - - return version.includes("≤"); - } - get flags(): FlagStatement[] { return this.data?.flags ?? []; } @@ -107,9 +85,29 @@ export class SupportStatement { return this.data?.version_added; } - get version_removed(): VersionValue { - // Strictness guarantee: unset version_removed returns false - return this.data?.version_removed || false; + get version_removed(): string | boolean | undefined { + const value = this.data?.version_removed; + + // TODO: Report and fix upstream bug in BCD, then uncomment or drop the code + // below + + // According to @mdn/browser-compat-data's schema, `version_removed` values + // should only be `true` or strings. In practice (and according to the + // exported types), this is not the case, because mirroring inserts `false` + // values. + + // if (value === null || value === false) { + // throw new Error( + // "`version_added` should never be `null` or `false`. This is a bug, so please file an issue!", + // ); + // } + if (value === null) { + throw new Error( + "`version_added` should never be `null`. This is a bug, so please file an issue!", + ); + } + + return value; } } @@ -140,17 +138,113 @@ export class RealSupportStatement extends SupportStatement { } } - get version_added() { + get version_added(): string | false { return super.version_added as string | false; } - get version_removed() { - return super.version_removed as string | false; + get version_removed(): string | false | undefined { + return super.version_removed as string | false | undefined; + } + + /** + * Find out whether this support statement says a given browser release is + * supported (with or without qualifications), unsupported, or unknown. + */ + supportedIn(release: Release): Supported | Unsupported | UnknownSupport { + if (this.browser === undefined) { + throw new Error("This support statement's browser is unknown."); + } + + if (release.browser !== this.browser) { + throw new Error( + "Browser-release mismatch. The release is not part of the statement's browser's set of releases.", + ); + } + + if (this.version_added === false) { + return { supported: false }; + } + + // From here, some releases might be supporting + const qualifications = statementToQualifications(this); + const asSupported = Boolean(Object.keys(qualifications).length) + ? { supported: true, qualifications } + : { supported: true }; + + // Let's deal with the most fiendish case first: + // { version_added: "≤", version_removed: "≤…" } + // That is, a case where unknown values are in two version ranges: + // - Supported in version_added + // - Unsupported from version_removed + // - Unknown before version_added + // - Unknown from version_added + 1 to removed (exclusive) + if ( + isRangedVersion(this.version_added) && + isRangedVersion(this.version_removed) + ) { + const supportedIn = this.browser.version( + this.version_added.replaceAll("≤", ""), + ) as Release; + const unsupportedFrom = this.browser.version( + this.version_removed.replaceAll("≤", ""), + ) as Release; + + if (release === supportedIn) { + return asSupported; + } + if (release.inRange(unsupportedFrom)) { + return { supported: false }; + } + return { supported: null }; + } + + const initial = this.browser.releases[0] as Release; + + // The other fiendish case is: + // { version_added: "…", version_removed: "≤…" } + // That is, cases such that: + // - Supported in version_added + // - Unsupported before version_added + // - Unsupported from version_removed + // - Unknown from version_added + 1 to removed (exclusive) + if ( + isFixedVersion(this.version_added) && + isRangedVersion(this.version_removed) + ) { + const supportedIn = this.browser.version(this.version_added); + const unsupportedFrom = this.browser.version( + this.version_removed.replaceAll("≤", ""), + ) as Release; + + if (release === supportedIn) { + return asSupported; + } + if ( + release.inRange(unsupportedFrom) || + release.inRange(initial, supportedIn) + ) { + return { supported: false }; + } + return { supported: null }; + } + + const start = this.browser.version(this.version_added.replaceAll("≤", "")); + const startRanged = isRangedVersion(this.version_added); + + const end: Release | undefined = + typeof this.version_removed === "string" + ? this.browser.version(this.version_removed) + : undefined; + + if (release.inRange(start, end)) { + return asSupported; + } + if (startRanged && release.inRange(initial, start)) { + return { supported: null }; + } + return { supported: false }; } - // TODO: `supportedBy()` ought to be (partially) implemented on non-real value - // support statements. For example, `"version_added": true` should allow for - // returning `[this.browser.current()]` at least. supportedBy(): { release: Release; qualifications?: Qualifications }[] { if (this.browser === undefined) { throw Error("This support statement's browser is unknown."); @@ -168,28 +262,14 @@ export class RealSupportStatement extends SupportStatement { } let releases; - if (this.version_removed === false) { - releases = this.browser.releases.filter((rel) => rel.compare(start) >= 0); // Release is on or after start + if (this.version_removed === undefined || this.version_removed === false) { + releases = this.browser.releases.filter((rel) => rel.inRange(start)); } else { const end: Release = this.browser.version(this.version_removed); - releases = this.browser.releases.filter( - (rel) => rel.compare(start) >= 0 && rel.compare(end) < 0, - ); // Release is on or after start and before the end + releases = this.browser.releases.filter((rel) => rel.inRange(start, end)); } - let qualifications: Qualifications = {}; - if (this.data.prefix) { - qualifications.prefix = this.data.prefix; - } - if (this.data.alternative_name) { - qualifications.alternative_name = this.data.alternative_name; - } - if (this.flags.length) { - qualifications.flags = this.flags; - } - if (this.partial_implementation) { - qualifications.partial_implementation = this.partial_implementation; - } + let qualifications: Qualifications = statementToQualifications(this); if (Object.keys(qualifications).length) { return releases.map((release) => ({ release, qualifications })); } @@ -198,3 +278,30 @@ export class RealSupportStatement extends SupportStatement { })); } } + +function statementToQualifications( + statement: SupportStatement, +): Qualifications { + let qualifications: Qualifications = {}; + if (statement.data.prefix) { + qualifications.prefix = statement.data.prefix; + } + if (statement.data.alternative_name) { + qualifications.alternative_name = statement.data.alternative_name; + } + if (statement.flags.length) { + qualifications.flags = statement.flags; + } + if (statement.partial_implementation) { + qualifications.partial_implementation = statement.partial_implementation; + } + return qualifications; +} + +function isRangedVersion(s: any): s is string { + return typeof s === "string" && s.startsWith("≤"); +} + +function isFixedVersion(s: any): s is string { + return typeof s === "string" && !s.startsWith("≤"); +}