Skip to content

Commit

Permalink
Improve handling of pre-release versions (#85)
Browse files Browse the repository at this point in the history
  • Loading branch information
mskelton committed Mar 21, 2024
1 parent 235ee5f commit 324f337
Show file tree
Hide file tree
Showing 54 changed files with 229 additions and 42 deletions.
12 changes: 6 additions & 6 deletions bundles/@yarnpkg/plugin-outdated.js

Large diffs are not rendered by default.

29 changes: 20 additions & 9 deletions src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,14 +28,25 @@ export function getHomepageURL({ raw: manifest }: Manifest): string | null {
: repoURL
}

/**
* Because some packages have a pre-release version as their `latest` version,
* we need to first check if the latest version is a pre-release. If it is,
* we compare the current and latest directly, otherwise we coerce the current
* version to remove any pre-release identifiers to determine if it is outdated.
*/
const isNumber = (value: string | number): value is number =>
typeof value === "number"

const padArray = (arr: number[], length: number) =>
arr.concat(Array(length - arr.length).fill(0))

const parsePreRelease = (prerelease: readonly (string | number)[]) =>
padArray(prerelease.filter(isNumber), 3).join(".")

export function isVersionOutdated(current: string, latest: string) {
return semver.parse(latest)!.prerelease.length
? semver.lt(current, latest)
: semver.lt(semver.coerce(current)!, latest)
const latestPrerelease = semver.prerelease(latest)
const currentPrerelease = semver.prerelease(current)

if (semver.eq(current, latest) && latestPrerelease && currentPrerelease) {
return semver.lt(
parsePreRelease(currentPrerelease),
parsePreRelease(latestPrerelease)
)
}

return semver.lt(current, latest)
}
6 changes: 4 additions & 2 deletions test/fixtures/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,20 +5,22 @@ type Environment = Awaited<ReturnType<typeof makeTemporaryEnv>>

interface EnvironmentFixtures extends Omit<Environment, "destroy"> {
env: Record<string, string>
latestVersions: Record<string, string>
yarnEnv: Omit<Environment, "destroy">
}

export const test = base.extend<EnvironmentFixtures>({
cwd: ({ yarnEnv }, use) => use(yarnEnv.cwd),
env: {},
latestVersions: {},
readFile: ({ yarnEnv }, use) => use(yarnEnv.readFile),
registry: ({ yarnEnv }, use) => use(yarnEnv.registry),
run: ({ yarnEnv }, use) => use(yarnEnv.run),
writeFile: ({ yarnEnv }, use) => use(yarnEnv.writeFile),
writeJSON: ({ yarnEnv }, use) => use(yarnEnv.writeJSON),
yarnEnv: async ({ env }, use, testInfo) => {
yarnEnv: async ({ env, latestVersions }, use, testInfo) => {
testInfo.snapshotSuffix = ""
const { destroy, ...yarnEnv } = await makeTemporaryEnv(env)
const { destroy, ...yarnEnv } = await makeTemporaryEnv(env, latestVersions)
await use(yarnEnv)
await destroy()
},
Expand Down
5 changes: 5 additions & 0 deletions test/packages/patch-1.0.1-alpha.2/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"name": "patch",
"version": "1.0.1-alpha.2",
"homepage": "https://github.com/mskelton/patch#readme"
}
7 changes: 7 additions & 0 deletions test/packages/rc-1.0.0-rc.1/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"name": "rc",
"version": "1.0.0-rc.1",
"repository": {
"url": "https://github.com/mskelton/rc"
}
}
7 changes: 7 additions & 0 deletions test/packages/rc-1.0.0/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"name": "rc",
"version": "1.0.0",
"repository": {
"url": "https://github.com/mskelton/rc"
}
}
7 changes: 7 additions & 0 deletions test/packages/rc-1.0.1/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"name": "rc",
"version": "1.0.1",
"repository": {
"url": "https://github.com/mskelton/rc"
}
}
File renamed without changes.
File renamed without changes.
File renamed without changes.
5 changes: 0 additions & 5 deletions test/specs/outdated.spec.ts-snapshots/pre-releases.txt

This file was deleted.

119 changes: 112 additions & 7 deletions test/specs/outdated.spec.ts → test/specs/outdated.test.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { isVersionOutdated } from "../../src/utils"
import { expect, test } from "../fixtures/env"
import { readSupplementalFile, writeSupplementalFile } from "../utils/files"

Expand Down Expand Up @@ -143,14 +144,118 @@ test.describe("yarn outdated", () => {
expect(stderr).toBe("")
})

test("ignores pre-release versions", async ({ run, writeJSON }) => {
await writeJSON("package.json", {
dependencies: { patch: "1.0.1-alpha.1" },
test.describe("pre-releases", () => {
test.describe(() => {
test.use({ latestVersions: { rc: "1.0.1" } })

test("current version is pre-release with newer version", async ({
run,
writeJSON,
}) => {
await writeJSON("package.json", { dependencies: { rc: "1.0.0-rc.1" } })
await run("install")

const { stderr, stdout } = await run("outdated")
expect(stdout).toMatchSnapshot("has-new-pre-release.txt")
expect(stderr).toBe("")
})
})
await run("install")

const { stderr, stdout } = await run("outdated")
expect(stdout).toMatchSnapshot("pre-releases.txt")
expect(stderr).toBe("")
test.describe(() => {
test.use({ latestVersions: { patch: "1.0.1" } })

test("current: non pre-release, latest: non pre-release", async ({
run,
writeJSON,
}) => {
await writeJSON("package.json", { dependencies: { patch: "1.0.0" } })
await run("install")

const { stderr, stdout } = await run("outdated")
expect(stdout).toMatchSnapshot("current-non-pre-latest-non-pre.txt")
expect(stderr).toBe("")
})
})

test.describe(() => {
test.use({ latestVersions: { patch: "1.0.1-alpha.1" } })

test("current: non pre-release, latest: pre-release", async ({
run,
writeJSON,
}) => {
await writeJSON("package.json", { dependencies: { patch: "1.0.0" } })
await run("install")

const { stderr, stdout } = await run("outdated")
expect(stdout).toMatchSnapshot("current-non-pre-latest-pre.txt")
expect(stderr).toBe("")
})
})

test.describe(() => {
test.use({ latestVersions: { patch: "1.0.1" } })

test("current: pre-release, latest: non pre-release", async ({
run,
writeJSON,
}) => {
await writeJSON("package.json", {
dependencies: { patch: "1.0.1-alpha.1" },
})
await run("install")

const { stderr, stdout } = await run("outdated")
expect(stdout).toMatchSnapshot("current-pre-latest-non-pre.txt")
expect(stderr).toBe("")
})
})

test.describe(() => {
test.use({ latestVersions: { patch: "1.0.1-alpha.2" } })

test("current: pre-release, latest: pre-release", async ({
run,
writeJSON,
}) => {
await writeJSON("package.json", {
dependencies: { patch: "1.0.1-alpha.1" },
})
await run("install")

const { stderr, stdout } = await run("outdated")
expect(stdout).toMatchSnapshot("current-pre-latest-pre.txt")
expect(stderr).toBe("")
})
})
})
})

test("isVersionOutdated", () => {
expect(isVersionOutdated("1.0.0", "1.0.0")).toBe(false)
expect(isVersionOutdated("1.0.0", "1.0.1")).toBe(true)
expect(isVersionOutdated("1.0.0", "1.0.1-rc.1")).toBe(true)

// Old pre-release
expect(isVersionOutdated("1.0.0-rc.1", "1.0.0")).toBe(true)
expect(isVersionOutdated("1.0.0-rc.1", "1.0.1")).toBe(true)
expect(isVersionOutdated("1.0.0-rc.1", "1.0.0-rc.1")).toBe(false)
expect(isVersionOutdated("1.0.0-rc.1", "1.0.0-rc.2")).toBe(true)
expect(isVersionOutdated("1.0.0-rc.1", "1.0.1-rc.1")).toBe(true)

// Pre-release past the latest non-pre-release
expect(isVersionOutdated("1.0.1-rc.1", "1.0.0")).toBe(false)
expect(isVersionOutdated("1.0.1-rc.1", "1.0.0-rc.1")).toBe(false)
expect(isVersionOutdated("1.0.1-rc.1", "1.0.1-rc.1")).toBe(false)

// https://semver.org
expect(isVersionOutdated("1.0.0-alpha", "1.0.0-alpha")).toBe(false)
expect(isVersionOutdated("1.0.0-alpha.1", "1.0.0-alpha.1")).toBe(false)
expect(isVersionOutdated("1.0.0-alpha.1", "1.0.0-alpha.2")).toBe(true)
expect(isVersionOutdated("1.0.0-0.3.7", "1.0.0-0.3.7")).toBe(false)
expect(isVersionOutdated("1.0.0-0.3.7", "1.0.0-0.4.7")).toBe(true)
expect(isVersionOutdated("1.0.0-x.7.z.92", "1.0.0-x.7.z.92")).toBe(false)
expect(isVersionOutdated("1.0.0-x.7.z.92", "1.0.0-x.7.z.93")).toBe(true)
expect(isVersionOutdated("1.0.0-x.7.z.92", "1.0.0-x.8.z.92")).toBe(true)
expect(isVersionOutdated("1.0.0-x.y.z", "1.0.0-x.y.z")).toBe(false)
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
➤ YN0000: ┌ Checking for outdated dependencies
➤ YN0000: └ Completed

➤ YN0000: Package Current Latest Package Type
➤ YN0000: patch 1.0.0 1.0.1-alpha.1 dependencies

➤ YN0000: 1 dependency is out of date
➤ YN0000: Done with warnings
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
➤ YN0000: ┌ Checking for outdated dependencies
➤ YN0000: └ Completed

➤ YN0000: Package Current Latest Package Type
➤ YN0000: patch 1.0.1-alpha.1 1.0.1 dependencies

➤ YN0000: 1 dependency is out of date
➤ YN0000: Done with warnings
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
➤ YN0000: ┌ Checking for outdated dependencies
➤ YN0000: └ Completed

➤ YN0000: Package Current Latest Package Type
➤ YN0000: patch 1.0.1-alpha.1 1.0.1-alpha.2 dependencies

➤ YN0000: 1 dependency is out of date
➤ YN0000: Done with warnings
8 changes: 8 additions & 0 deletions test/specs/outdated.test.ts-snapshots/has-new-pre-release.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
➤ YN0000: ┌ Checking for outdated dependencies
➤ YN0000: └ Completed

➤ YN0000: Package Current Latest Package Type
➤ YN0000: rc 1.0.0-rc.1 1.0.1 dependencies

➤ YN0000: 1 dependency is out of date
➤ YN0000: Done with warnings
File renamed without changes.
8 changes: 8 additions & 0 deletions test/specs/patterns.test.ts-snapshots/exact.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
➤ YN0000: ┌ Checking for outdated dependencies
➤ YN0000: └ Completed

➤ YN0000: Package Current Latest Package Type
➤ YN0000: patch 1.0.0 1.0.1 dependencies

➤ YN0000: 1 dependency is out of date
➤ YN0000: Done with warnings
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
26 changes: 16 additions & 10 deletions test/utils/Registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import { Gzip } from "node:zlib"
import semver from "semver"
import * as fsUtils from "./fs"

let packages: Map<string, Map<string, PackageEntry>> = null!

type Request =
| {
localName: string
Expand All @@ -33,13 +35,14 @@ export class Registry {
}

public port: number = null!
private packages: Map<string, Map<string, PackageEntry>> = null!
private serverUrl: Promise<string> = null!

constructor(private latestVersions: Record<string, string>) {}

async start() {
// Packages on the regsitry don't change from test to test,
// so we only load them once
if (!this.packages) {
if (!packages) {
await this.loadPackages()
}

Expand Down Expand Up @@ -69,7 +72,7 @@ export class Registry {
}

private async loadPackages() {
this.packages = new Map()
packages = new Map()

// Load the registry packages from the packages directory
const manifests = await glob("**/package.json", {
Expand All @@ -82,9 +85,9 @@ export class Registry {
const { name, version } = packageJson

// Create the package entry if it doesn't exist
let packageEntry = this.packages.get(name)
let packageEntry = packages.get(name)
if (!packageEntry) {
this.packages.set(name, (packageEntry = new Map()))
packages.set(name, (packageEntry = new Map()))
}

packageEntry.set(version, {
Expand All @@ -96,13 +99,13 @@ export class Registry {

private async process(
request: Request,
req: http.IncomingMessage,
_: http.IncomingMessage,
res: http.ServerResponse
) {
const { localName, scope } = request
const name = scope ? `${scope}/${localName}` : localName

const packageEntry = this.packages.get(name)
const packageEntry = packages.get(name)
if (!packageEntry) {
return this.sendError(res, 404, `Package not found: ${name}`)
}
Expand All @@ -122,7 +125,10 @@ export class Registry {
}))

const data = {
"dist-tags": { latest: semver.maxSatisfying(versions, "*") },
"dist-tags": {
latest:
this.latestVersions[name] ?? semver.maxSatisfying(versions, "*"),
},
name,
versions: Object.assign({}, ...(await Promise.all(versionEntries))),
}
Expand Down Expand Up @@ -158,7 +164,7 @@ export class Registry {
}

async getPackageArchiveStream(name: string, version: string): Promise<Gzip> {
const packageEntry = this.packages.get(name)
const packageEntry = packages.get(name)
if (!packageEntry) {
throw new Error(`Unknown package "${name}"`)
}
Expand Down Expand Up @@ -197,7 +203,7 @@ export class Registry {
}

async getPackageHttpArchivePath(name: string, version: string) {
const packageEntry = this.packages.get(name)
const packageEntry = packages.get(name)
if (!packageEntry) {
throw new Error(`Unknown package "${name}"`)
}
Expand Down
8 changes: 5 additions & 3 deletions test/utils/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,12 @@ interface RunOptions {
env?: Record<string, string>
}

// We only need to setup the registry once
const registry = new Registry()
export async function makeTemporaryEnv(
globalEnv: Record<string, string>,
latestVersions: Record<string, string>
) {
const registry = new Registry(latestVersions)

export async function makeTemporaryEnv(globalEnv: Record<string, string>) {
const [tempDir, homeDir, registryUrl] = await Promise.all([
xfs.mktempPromise(),
xfs.mktempPromise(),
Expand Down

0 comments on commit 324f337

Please sign in to comment.