Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Improve handling of pre-release versions #85

Merged
merged 11 commits into from
Mar 21, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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.
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
Loading