From 8f7bb77bca89a110ddc47118032693bf1ab20a0d Mon Sep 17 00:00:00 2001 From: Holly Cummins Date: Tue, 14 Jan 2025 11:27:06 +0000 Subject: [PATCH] Add pages for each month's releases --- gatsby-node.js | 46 +- gatsby-node.test.js | 1146 ++++++++++--------- plugins/github-enricher/gatsby-node.js | 1 + src/components/extension-card.js | 4 +- src/components/sortings/sortings.js | 1 - src/components/util/extension-slugger.js | 9 +- src/maven/maven-info.js | 26 +- src/templates/extensions-added-list.js | 259 +++++ src/templates/extensions-added-list.test.js | 114 ++ 9 files changed, 1046 insertions(+), 560 deletions(-) create mode 100644 src/templates/extensions-added-list.js create mode 100644 src/templates/extensions-added-list.test.js diff --git a/gatsby-node.js b/gatsby-node.js index accc3282074c..970db59c8e14 100644 --- a/gatsby-node.js +++ b/gatsby-node.js @@ -6,7 +6,11 @@ const { getStream, } = require("./src/components/util/pretty-platform") const { sortableName } = require("./src/components/util/sortable-name") -const { extensionSlug, extensionSlugFromCoordinates } = require("./src/components/util/extension-slugger") +const { + extensionSlug, + extensionSlugFromCoordinates, + slugForExtensionsAddedMonth +} = require("./src/components/util/extension-slugger") const { generateMavenInfo, initMavenCache, saveMavenCache } = require("./src/maven/maven-info") const { createRemoteFileNode } = require("gatsby-source-filesystem") const { rewriteGuideUrl } = require("./src/components/util/guide-url-rewriter") @@ -232,6 +236,7 @@ exports.createPages = async ({ graphql, actions, reporter }) => { // Define a template for an extension const extensionTemplate = path.resolve(`./src/templates/extension-detail.js`) + const releaseMonthTemplate = path.resolve(`./src/templates/extensions-added-list.js`) // Get all extensions const result = await graphql( @@ -242,6 +247,11 @@ exports.createPages = async ({ graphql, actions, reporter }) => { id slug isSuperseded + metadata { + maven { + sinceMonth + } + } } } } @@ -256,27 +266,46 @@ exports.createPages = async ({ graphql, actions, reporter }) => { return } - const posts = result.data.allExtension.nodes + const extensionNodes = result.data.allExtension.nodes // Create extension pages // `context` is available in the template as a prop and as a variable in GraphQL - if (posts.length > 0) { - posts.forEach((post, index) => { - const previousPostId = getPreviousPost(index, posts) - const nextPostId = getNextPost(index, posts) + if (extensionNodes.length > 0) { + extensionNodes.forEach((extensionNode, index) => { + const previousPostId = getPreviousPost(index, extensionNodes) + const nextPostId = getNextPost(index, extensionNodes) createPage({ - path: post.slug, + path: extensionNode.slug, component: extensionTemplate, context: { - id: post.id, + id: extensionNode.id, previousPostId, nextPostId, }, }) }) } + + const months = [...new Set(extensionNodes.map(extensionNode => extensionNode.metadata?.maven?.sinceMonth))].sort() + + months.forEach((month, index, array) => { + + + const slug = slugForExtensionsAddedMonth(month) + const previousMonthTimestamp = array[index - 1] + const nextMonthTimestamp = array[index + 1] + createPage({ + path: slug, + component: releaseMonthTemplate, + context: { + sinceMonth: month, + previousMonthTimestamp, + nextMonthTimestamp, + }, + }) + }) } const getPreviousPost = (index, posts) => { @@ -362,6 +391,7 @@ exports.createSchemaCustomization = ({ actions }) => { version: String timestamp: String since: String + sinceMonth: String relocation: RelocationInfo } diff --git a/gatsby-node.test.js b/gatsby-node.test.js index c464652909ae..d1d742918435 100644 --- a/gatsby-node.test.js +++ b/gatsby-node.test.js @@ -8,16 +8,17 @@ jest.mock("gatsby-source-filesystem") const imageValidation = require("./src/data/image-validation") jest.mock("./src/data/image-validation") -const { sourceNodes } = require("./gatsby-node") +const { sourceNodes, createPages } = require("./gatsby-node") const createNode = jest.fn() const createNodeId = jest.fn() +const createPage = jest.fn() const createContentDigest = jest.fn() const resolvedJavadocUrl = "http://reallygoodurl.javadoc" const { createJavadocUrlFromCoordinates } = require("./src/javadoc/javadoc-url") jest.mock("./src/javadoc/javadoc-url") -createJavadocUrlFromCoordinates.mockImplementation(artifactId => { +createJavadocUrlFromCoordinates.mockImplementation(() => { return resolvedJavadocUrl }) @@ -45,7 +46,7 @@ generateMavenInfo.mockImplementation(artifactId => { return coordinates }) -const actions = { createNode } +const actions = { createNode, createPage } // A cut down version of what the registry returns us, with just the relevant bits const currentPlatforms = { platforms: [ @@ -72,315 +73,188 @@ const currentPlatforms = { } describe("the main gatsby entrypoint", () => { - describe("for an extension with no data", () => { - const extension = {} - // Be a bit lazy and smoosh the responses from two distinct endpoints into our axios endpoint, since they do not overlap - beforeAll(async () => { - axios.get = jest.fn().mockReturnValue({ - data: { extensions: [extension], platforms: currentPlatforms }, - }) - - await sourceNodes({ actions, createNodeId, createContentDigest }) - }) + describe("creating graphql nodes", () => { - afterAll(() => { - jest.clearAllMocks() - }) + describe("for an extension with no data", () => { + const extension = {} - it("creates a node and fills in empty metadata", () => { - expect(createNode).toHaveBeenCalledWith( - expect.objectContaining({ - metadata: {}, + // Be a bit lazy and smoosh the responses from two distinct endpoints into our axios endpoint, since they do not overlap + beforeAll(async () => { + axios.get = jest.fn().mockReturnValue({ + data: { extensions: [extension], platforms: currentPlatforms }, }) - ) - }) - - it("creates an id", () => { - expect(createNodeId).toHaveBeenCalled() - }) - }) - describe("for a typical extension", () => { - const extension = { - artifact: - "io.quarkiverse.micrometer.registry:quarkus-micrometer-registry-datadog::jar:2.12.0", - origins: [ - "io.quarkus.platform:quarkus-bom-quarkus-platform-descriptor:3.0.0.Alpha3:json:3.0.0.Alpha3", - ], - metadata: { - status: "shaky", - categories: ["round", "square"], - }, - } - // A cut down version of what the registry returns us, with just the relevant bits - const currentPlatforms = { - platforms: [ - { - "platform-key": "io.quarkus.platform", - name: "Quarkus Community Platform", - streams: [ - { - id: "2.16", - }, - { - id: "2.15", - }, - { - id: "2.13", - }, - { - id: "3.0", - }, - ], - "current-stream-id": "2.16", - }, - ], - } - beforeAll(async () => { - axios.get = jest.fn().mockReturnValue({ - data: { - extensions: [extension], - platforms: currentPlatforms.platforms, - }, + await sourceNodes({ actions, createNodeId, createContentDigest }) }) - await sourceNodes({ actions, createNodeId, createContentDigest }) - }) - - afterAll(() => { - jest.clearAllMocks() - }) - - it("creates an id", () => { - expect(createNodeId).toHaveBeenCalled() - }) - - it("creates extension nodes", () => { - expect(createNode).toHaveBeenCalledWith( - expect.objectContaining({ - internal: expect.objectContaining({ - type: "Extension", - }), - }) - ) - }) - - it("sets a platform", () => { - expect(createNode).toHaveBeenCalledWith( - expect.objectContaining({ - platforms: ["quarkus-bom-quarkus-platform-descriptor"], - }) - ) - }) - - it("sets a stream", () => { - expect(createNode).toHaveBeenCalledWith( - expect.objectContaining({ - streams: [ - expect.objectContaining({ - platformKey: "io.quarkus.platform", - id: "3.0", - }), - ], - }) - ) - }) - - it("sets a status by wrapping the value in an array", () => { - expect(createNode).toHaveBeenCalledWith( - expect.objectContaining({ - metadata: expect.objectContaining({ - status: ["shaky"], - }), - }) - ) - }) - - it("marks the as stream as current", () => { - expect(createNode).toHaveBeenCalledWith( - expect.objectContaining({ - streams: [expect.objectContaining({ isLatestThree: true })], - }) - ) - }) - - it("adds a maven url", () => { - expect(createNode).toHaveBeenCalledWith( - expect.objectContaining({ - metadata: expect.objectContaining({ - maven: expect.objectContaining({ - url: resolvedMavenUrl, - }), - }), - }) - ) - }) - - it("adds a javadoc url", () => { - expect(createNode).toHaveBeenCalledWith( - expect.objectContaining({ - metadata: expect.objectContaining({ - javadoc: expect.objectContaining({ - url: resolvedJavadocUrl, - }), - }), - }) - ) - }) - - it("creates a category node", () => { - expect(createNode).toHaveBeenCalledWith( - expect.objectContaining({ - name: "round", - internal: expect.objectContaining({ - type: "Category", - }), - }) - ) - - expect(createNode).toHaveBeenCalledWith( - expect.objectContaining({ - name: "square", - internal: expect.objectContaining({ - type: "Category", - }), - }) - ) - }) - }) - - describe("for an extension with an array of statuses", () => { - const extension = { - artifact: - "io.quarkiverse.micrometer.registry:quarkus-micrometer-registry-datadog::jar:2.12.0", - origins: [ - "io.quarkus.platform:quarkus-bom-quarkus-platform-descriptor:3.0.0.Alpha3:json:3.0.0.Alpha3", - ], - metadata: { - status: ["questionable", "dodgy"], - }, - } - - beforeAll(async () => { - axios.get = jest.fn().mockReturnValue({ - data: { - extensions: [extension], - platforms: [], - }, + afterAll(() => { + jest.clearAllMocks() }) - await sourceNodes({ actions, createNodeId, createContentDigest }) - }) - - afterAll(() => { - jest.clearAllMocks() - }) + it("creates a node and fills in empty metadata", () => { + expect(createNode).toHaveBeenCalledWith( + expect.objectContaining({ + metadata: {}, + }) + ) + }) - it("passes through the array status", () => { - expect(createNode).toHaveBeenCalledWith( - expect.objectContaining({ - metadata: expect.objectContaining({ - status: ["questionable", "dodgy"], - }), - }) - ) + it("creates an id", () => { + expect(createNodeId).toHaveBeenCalled() + }) }) - }) - describe("for an extension with an icon-url", () => { - const getCache = jest.fn().mockReturnValue({}) - - - describe("where the icon is a dead link", () => { - // This test needs to go first, because there's some cross-talk on the mocks I can't quite figure out + describe("for a typical extension", () => { const extension = { artifact: "io.quarkiverse.micrometer.registry:quarkus-micrometer-registry-datadog::jar:2.12.0", + origins: [ + "io.quarkus.platform:quarkus-bom-quarkus-platform-descriptor:3.0.0.Alpha3:json:3.0.0.Alpha3", + ], metadata: { - "icon-url": "missing.png" - } + status: "shaky", + categories: ["round", "square"], + }, } - - - beforeAll(async () => { - jest.mock("axios") - // First call is the registry data, second is the platforms, third is the contents of the image url - axios.get = jest.fn().mockReturnValueOnce({ - data: { - extensions: [extension], - platforms: [], + // A cut down version of what the registry returns us, with just the relevant bits + const currentPlatforms = { + platforms: [ + { + "platform-key": "io.quarkus.platform", + name: "Quarkus Community Platform", + streams: [ + { + id: "2.16", + }, + { + id: "2.15", + }, + { + id: "2.13", + }, + { + id: "3.0", + }, + ], + "current-stream-id": "2.16", }, - }).mockReturnValueOnce({ + ], + } + beforeAll(async () => { + axios.get = jest.fn().mockReturnValue({ data: { extensions: [extension], - platforms: [], + platforms: currentPlatforms.platforms, }, - }).mockRejectedValueOnce(new Error("missing contents")) + }) - await sourceNodes({ actions, getCache, createNodeId, createContentDigest }) + await sourceNodes({ actions, createNodeId, createContentDigest }) }) afterAll(() => { jest.clearAllMocks() }) - it("creates a node but without an icon url", () => { - expect(createNode).not.toHaveBeenCalledWith( + it("creates an id", () => { + expect(createNodeId).toHaveBeenCalled() + }) + + it("creates extension nodes", () => { + expect(createNode).toHaveBeenCalledWith( expect.objectContaining({ - metadata: expect.objectContaining({ - icon: expect.anything(), + internal: expect.objectContaining({ + type: "Extension", }), }) ) }) - }) - - describe("where the icon points to a valid image", () => { - const extension = { - artifact: - "io.quarkiverse.micrometer.registry:quarkus-micrometer-registry-datadog::jar:2.12.0", - metadata: { - "icon-url": "http://valid.png" - } - } + it("sets a platform", () => { + expect(createNode).toHaveBeenCalledWith( + expect.objectContaining({ + platforms: ["quarkus-bom-quarkus-platform-descriptor"], + }) + ) + }) - beforeAll(async () => { - // First call is the registry data, second is the contents of the image url - axios.get = jest.fn().mockReturnValueOnce({ - data: { - extensions: [extension], - platforms: [], - }, - }).mockReturnValueOnce({ - data: [], - }) + it("sets a stream", () => { + expect(createNode).toHaveBeenCalledWith( + expect.objectContaining({ + streams: [ + expect.objectContaining({ + platformKey: "io.quarkus.platform", + id: "3.0", + }), + ], + }) + ) + }) - imageValidation.validate.mockResolvedValue(true) + it("sets a status by wrapping the value in an array", () => { + expect(createNode).toHaveBeenCalledWith( + expect.objectContaining({ + metadata: expect.objectContaining({ + status: ["shaky"], + }), + }) + ) + }) - await sourceNodes({ actions, getCache, createNodeId, createContentDigest }) + it("marks the as stream as current", () => { + expect(createNode).toHaveBeenCalledWith( + expect.objectContaining({ + streams: [expect.objectContaining({ isLatestThree: true })], + }) + ) }) - afterAll(() => { - jest.clearAllMocks() + it("adds a maven url", () => { + expect(createNode).toHaveBeenCalledWith( + expect.objectContaining({ + metadata: expect.objectContaining({ + maven: expect.objectContaining({ + url: resolvedMavenUrl, + }), + }), + }) + ) }) - it("creates a node with an icon url", () => { + it("adds a javadoc url", () => { expect(createNode).toHaveBeenCalledWith( expect.objectContaining({ metadata: expect.objectContaining({ - icon: "http://valid.png", + javadoc: expect.objectContaining({ + url: resolvedJavadocUrl, + }), }), }) ) }) - }) + it("creates a category node", () => { + expect(createNode).toHaveBeenCalledWith( + expect.objectContaining({ + name: "round", + internal: expect.objectContaining({ + type: "Category", + }), + }) + ) - describe("where the icon is a github blob page", () => { - const blobPath = - "https://github.com/quarkiverse/quarkus-extension/blob/main/docs/img/nope.png" + expect(createNode).toHaveBeenCalledWith( + expect.objectContaining({ + name: "square", + internal: expect.objectContaining({ + type: "Category", + }), + }) + ) + }) + }) + + describe("for an extension with an array of statuses", () => { const extension = { artifact: "io.quarkiverse.micrometer.registry:quarkus-micrometer-registry-datadog::jar:2.12.0", @@ -388,11 +262,10 @@ describe("the main gatsby entrypoint", () => { "io.quarkus.platform:quarkus-bom-quarkus-platform-descriptor:3.0.0.Alpha3:json:3.0.0.Alpha3", ], metadata: { - "icon-url": blobPath - } + status: ["questionable", "dodgy"], + }, } - beforeAll(async () => { axios.get = jest.fn().mockReturnValue({ data: { @@ -401,383 +274,570 @@ describe("the main gatsby entrypoint", () => { }, }) - await sourceNodes({ actions, getCache, createNodeId, createContentDigest }) + await sourceNodes({ actions, createNodeId, createContentDigest }) }) afterAll(() => { jest.clearAllMocks() }) - it("creates a node but without an icon url", () => { + it("passes through the array status", () => { expect(createNode).toHaveBeenCalledWith( expect.objectContaining({ - metadata: expect.not.objectContaining({ - icon: expect.anything(), + metadata: expect.objectContaining({ + status: ["questionable", "dodgy"], }), }) ) }) }) - describe("where the icon exists but is corrupted", () => { + describe("for an extension with an icon-url", () => { + const getCache = jest.fn().mockReturnValue({}) + + + describe("where the icon is a dead link", () => { + // This test needs to go first, because there's some cross-talk on the mocks I can't quite figure out + const extension = { + artifact: + "io.quarkiverse.micrometer.registry:quarkus-micrometer-registry-datadog::jar:2.12.0", + metadata: { + "icon-url": "missing.png" + } + } + + + beforeAll(async () => { + jest.mock("axios") + // First call is the registry data, second is the platforms, third is the contents of the image url + axios.get = jest.fn().mockReturnValueOnce({ + data: { + extensions: [extension], + platforms: [], + }, + }).mockReturnValueOnce({ + data: { + extensions: [extension], + platforms: [], + }, + }).mockRejectedValueOnce(new Error("missing contents")) + + await sourceNodes({ actions, getCache, createNodeId, createContentDigest }) + }) + + afterAll(() => { + jest.clearAllMocks() + }) + + it("creates a node but without an icon url", () => { + expect(createNode).not.toHaveBeenCalledWith( + expect.objectContaining({ + metadata: expect.objectContaining({ + icon: expect.anything(), + }), + }) + ) + }) + }) + + describe("where the icon points to a valid image", () => { + const extension = { + artifact: + "io.quarkiverse.micrometer.registry:quarkus-micrometer-registry-datadog::jar:2.12.0", + metadata: { + "icon-url": "http://valid.png" + } + } + + + beforeAll(async () => { + // First call is the registry data, second is the contents of the image url + axios.get = jest.fn().mockReturnValueOnce({ + data: { + extensions: [extension], + platforms: [], + }, + }).mockReturnValueOnce({ + data: [], + }) + + imageValidation.validate.mockResolvedValue(true) + + await sourceNodes({ actions, getCache, createNodeId, createContentDigest }) + }) + + afterAll(() => { + jest.clearAllMocks() + }) + + it("creates a node with an icon url", () => { + expect(createNode).toHaveBeenCalledWith( + expect.objectContaining({ + metadata: expect.objectContaining({ + icon: "http://valid.png", + }), + }) + ) + }) + }) + + + describe("where the icon is a github blob page", () => { + const blobPath = + "https://github.com/quarkiverse/quarkus-extension/blob/main/docs/img/nope.png" + const extension = { + artifact: + "io.quarkiverse.micrometer.registry:quarkus-micrometer-registry-datadog::jar:2.12.0", + origins: [ + "io.quarkus.platform:quarkus-bom-quarkus-platform-descriptor:3.0.0.Alpha3:json:3.0.0.Alpha3", + ], + metadata: { + "icon-url": blobPath + } + } + + + beforeAll(async () => { + axios.get = jest.fn().mockReturnValue({ + data: { + extensions: [extension], + platforms: [], + }, + }) + + await sourceNodes({ actions, getCache, createNodeId, createContentDigest }) + }) + + afterAll(() => { + jest.clearAllMocks() + }) + + it("creates a node but without an icon url", () => { + expect(createNode).toHaveBeenCalledWith( + expect.objectContaining({ + metadata: expect.not.objectContaining({ + icon: expect.anything(), + }), + }) + ) + }) + }) + + describe("where the icon exists but is corrupted", () => { + const extension = { + artifact: + "io.quarkiverse.micrometer.registry:quarkus-micrometer-registry-datadog::jar:2.12.0", + origins: [ + "io.quarkus.platform:quarkus-bom-quarkus-platform-descriptor:3.0.0.Alpha3:json:3.0.0.Alpha3", + ], + metadata: { + "icon-url": "invalid.png" + } + } + + + beforeAll(async () => { + axios.get = jest.fn().mockReturnValue({ + data: { + extensions: [extension], + platforms: [], + }, + }) + + imageValidation.validate.mockResolvedValue(false) + + await sourceNodes({ actions, getCache, createNodeId, createContentDigest }) + }) + + afterAll(() => { + jest.clearAllMocks() + }) + + it("does not creates a node with an icon url", () => { + expect(createNode).not.toHaveBeenCalledWith( + expect.objectContaining({ + metadata: expect.objectContaining({ + icon: expect.anything(), + }), + }) + ) + }) + }) + }) + + describe("for an extension in a very old platform", () => { const extension = { artifact: "io.quarkiverse.micrometer.registry:quarkus-micrometer-registry-datadog::jar:2.12.0", origins: [ - "io.quarkus.platform:quarkus-bom-quarkus-platform-descriptor:3.0.0.Alpha3:json:3.0.0.Alpha3", + "io.quarkus.platform:quarkus-bom-quarkus-platform-descriptor:2.2.0:json:2.2.0", ], - metadata: { - "icon-url": "invalid.png" - } } - beforeAll(async () => { - axios.get = jest.fn().mockReturnValue({ - data: { - extensions: [extension], - platforms: [], - }, - }) + axios.get = jest + .fn() + .mockReturnValue({ data: { extensions: [extension] } }) - imageValidation.validate.mockResolvedValue(false) - - await sourceNodes({ actions, getCache, createNodeId, createContentDigest }) + await sourceNodes({ actions, createNodeId, createContentDigest }) }) afterAll(() => { jest.clearAllMocks() }) - it("does not creates a node with an icon url", () => { - expect(createNode).not.toHaveBeenCalledWith( + it("creates an id", () => { + expect(createNodeId).toHaveBeenCalled() + }) + + it("sets a stream", () => { + expect(createNode).toHaveBeenCalledWith( expect.objectContaining({ - metadata: expect.objectContaining({ - icon: expect.anything(), - }), + streams: [ + expect.objectContaining({ + platformKey: "io.quarkus.platform", + id: "2.2", + }), + ], + }) + ) + }) + + it("marks the as stream as obsolete", () => { + expect(createNode).toHaveBeenCalledWith( + expect.objectContaining({ + streams: [expect.objectContaining({ isLatestThree: false })], }) ) }) }) - }) - describe("for an extension in a very old platform", () => { - const extension = { - artifact: - "io.quarkiverse.micrometer.registry:quarkus-micrometer-registry-datadog::jar:2.12.0", - origins: [ - "io.quarkus.platform:quarkus-bom-quarkus-platform-descriptor:2.2.0:json:2.2.0", - ], - } + describe("with unlisted set to false", () => { + const extension = { metadata: { unlisted: false } } - beforeAll(async () => { - axios.get = jest - .fn() - .mockReturnValue({ data: { extensions: [extension] } }) + beforeAll(async () => { + axios.get = jest + .fn() + .mockReturnValue({ data: { extensions: [extension] } }) - await sourceNodes({ actions, createNodeId, createContentDigest }) - }) + await sourceNodes({ actions, createNodeId, createContentDigest }) + }) - afterAll(() => { - jest.clearAllMocks() - }) + afterAll(() => { + jest.clearAllMocks() + }) - it("creates an id", () => { - expect(createNodeId).toHaveBeenCalled() + it("creates a node and fills the correct value for unlisted", () => { + expect(createNode).toHaveBeenCalledWith( + expect.objectContaining({ + metadata: { unlisted: false }, + }) + ) + }) }) - it("sets a stream", () => { - expect(createNode).toHaveBeenCalledWith( - expect.objectContaining({ - streams: [ - expect.objectContaining({ - platformKey: "io.quarkus.platform", - id: "2.2", - }), - ], - }) - ) - }) + describe("for an extension with unlisted", () => { + const extension = { metadata: { unlisted: true } } - it("marks the as stream as obsolete", () => { - expect(createNode).toHaveBeenCalledWith( - expect.objectContaining({ - streams: [expect.objectContaining({ isLatestThree: false })], - }) - ) - }) - }) + beforeAll(async () => { + axios.get = jest + .fn() + .mockReturnValue({ data: { extensions: [extension] } }) - describe("with unlisted set to false", () => { - const extension = { metadata: { unlisted: false } } + await sourceNodes({ actions, createNodeId, createContentDigest }) + }) - beforeAll(async () => { - axios.get = jest - .fn() - .mockReturnValue({ data: { extensions: [extension] } }) + afterAll(() => { + jest.clearAllMocks() + }) - await sourceNodes({ actions, createNodeId, createContentDigest }) - }) + it("creates a node and fills the correct value for unlisted", () => { + expect(createNode).toHaveBeenCalledWith( + expect.objectContaining({ + metadata: { unlisted: true }, + }) + ) + }) - afterAll(() => { - jest.clearAllMocks() + it("creates an id", () => { + expect(createNodeId).toHaveBeenCalled() + }) }) - it("creates a node and fills the correct value for unlisted", () => { - expect(createNode).toHaveBeenCalledWith( - expect.objectContaining({ - metadata: { unlisted: false }, - }) - ) - }) - }) + describe("for an extension with unlisted set to false", () => { + const extension = { metadata: { unlisted: false } } - describe("for an extension with unlisted", () => { - const extension = { metadata: { unlisted: true } } + beforeAll(async () => { + axios.get = jest + .fn() + .mockReturnValue({ data: { extensions: [extension] } }) - beforeAll(async () => { - axios.get = jest - .fn() - .mockReturnValue({ data: { extensions: [extension] } }) + await sourceNodes({ actions, createNodeId, createContentDigest }) + }) - await sourceNodes({ actions, createNodeId, createContentDigest }) - }) + afterAll(() => { + jest.clearAllMocks() + }) - afterAll(() => { - jest.clearAllMocks() + it("creates a node and fills the correct value for unlisted", () => { + expect(createNode).toHaveBeenCalledWith( + expect.objectContaining({ + metadata: { unlisted: false }, + }) + ) + }) }) - it("creates a node and fills the correct value for unlisted", () => { - expect(createNode).toHaveBeenCalledWith( - expect.objectContaining({ - metadata: { unlisted: true }, - }) - ) - }) + // in a handful of the extension yamls, the unlisted is a string, not a boolean. since this is a string, it drives graphQL mad with type anxiety, unless we help. + describe("for an extension with unlisted as a string", () => { + const extension = { metadata: { unlisted: "true" } } - it("creates an id", () => { - expect(createNodeId).toHaveBeenCalled() - }) - }) + beforeAll(async () => { + axios.get = jest + .fn() + .mockReturnValue({ data: { extensions: [extension] } }) - describe("for an extension with unlisted set to false", () => { - const extension = { metadata: { unlisted: false } } + await sourceNodes({ actions, createNodeId, createContentDigest }) + }) - beforeAll(async () => { - axios.get = jest - .fn() - .mockReturnValue({ data: { extensions: [extension] } }) + afterAll(() => { + jest.clearAllMocks() + }) - await sourceNodes({ actions, createNodeId, createContentDigest }) - }) + it("creates a node and fills the correct value for unlisted", () => { + expect(createNode).toHaveBeenCalledWith( + expect.objectContaining({ + metadata: { unlisted: true }, + }) + ) + }) - afterAll(() => { - jest.clearAllMocks() + it("creates an id", () => { + expect(createNodeId).toHaveBeenCalled() + }) }) - it("creates a node and fills the correct value for unlisted", () => { - expect(createNode).toHaveBeenCalledWith( - expect.objectContaining({ - metadata: { unlisted: false }, - }) - ) - }) - }) + // this *really* shouldn't happen, but if we're doing string to boolean conversion, we need to check false; treating string false as true is a common bug. + describe("for an extension with unlisted as a false string", () => { + const extension = { metadata: { unlisted: "false" } } - // in a handful of the extension yamls, the unlisted is a string, not a boolean. since this is a string, it drives graphQL mad with type anxiety, unless we help. - describe("for an extension with unlisted as a string", () => { - const extension = { metadata: { unlisted: "true" } } + beforeAll(async () => { + axios.get = jest + .fn() + .mockReturnValue({ data: { extensions: [extension] } }) - beforeAll(async () => { - axios.get = jest - .fn() - .mockReturnValue({ data: { extensions: [extension] } }) + await sourceNodes({ actions, createNodeId, createContentDigest }) + }) - await sourceNodes({ actions, createNodeId, createContentDigest }) - }) + afterAll(() => { + jest.clearAllMocks() + }) - afterAll(() => { - jest.clearAllMocks() - }) + it("creates a node and fills the correct value for unlisted", () => { + expect(createNode).toHaveBeenCalledWith( + expect.objectContaining({ + metadata: { unlisted: false }, + }) + ) + }) - it("creates a node and fills the correct value for unlisted", () => { - expect(createNode).toHaveBeenCalledWith( - expect.objectContaining({ - metadata: { unlisted: true }, - }) - ) + it("creates an id", () => { + expect(createNodeId).toHaveBeenCalled() + }) }) - it("creates an id", () => { - expect(createNodeId).toHaveBeenCalled() - }) - }) + describe("for an extension with a relocation to another extension", () => { + const extension = { + artifact: + "io.quarkiverse.micrometer.registry:quarkus-micrometer-registry-datadog::jar:2.12.0", + origins: [ + "io.quarkus.platform:quarkus-bom-quarkus-platform-descriptor:3.0.0.Alpha3:json:3.0.0.Alpha3", + ], + } + const olderExtension = { + artifact: + "io.quarkelsewhere:quarkus-micrometer-registry-datacat::jar:3.12.0", + origins: [ + "io.quarkus.platform:quarkus-bom-quarkus-platform-descriptor:3.0.0.Alpha3:json:3.0.0.Alpha3", + ] + } - // this *really* shouldn't happen, but if we're doing string to boolean conversion, we need to check false; treating string false as true is a common bug. - describe("for an extension with unlisted as a false string", () => { - const extension = { metadata: { unlisted: "false" } } + // A cut down version of what the registry returns us, with just the relevant bits + const currentPlatforms = { + platforms: [{}], + } + beforeAll(async () => { + axios.get = jest.fn().mockReturnValue({ + data: { + extensions: [extension, olderExtension], + platforms: currentPlatforms.platforms, + }, + }) - beforeAll(async () => { - axios.get = jest - .fn() - .mockReturnValue({ data: { extensions: [extension] } }) + await sourceNodes({ actions, createNodeId, createContentDigest }) + }) - await sourceNodes({ actions, createNodeId, createContentDigest }) - }) + afterAll(() => { + jest.clearAllMocks() + }) - afterAll(() => { - jest.clearAllMocks() - }) + it("creates ids", () => { + // It's not easy to do a 'greater than' here, so just hardcode + expect(createNodeId).toHaveBeenCalledTimes(2) + }) - it("creates a node and fills the correct value for unlisted", () => { - expect(createNode).toHaveBeenCalledWith( - expect.objectContaining({ - metadata: { unlisted: false }, - }) - ) - }) + it("adds a link to the newer extension from the older one", () => { + expect(createNode).toHaveBeenCalledWith( + expect.objectContaining({ + artifact: extension.artifact, + duplicates: [ + expect.objectContaining({ + groupId: "io.quarkelsewhere", + slug: "io.quarkelsewhere/quarkus-micrometer-registry-datacat", + }), + ], + }) + ) + }) - it("creates an id", () => { - expect(createNodeId).toHaveBeenCalled() - }) - }) + it("adds a link to the older extension from the new one", () => { + expect(createNode).toHaveBeenCalledWith( + expect.objectContaining({ + artifact: olderExtension.artifact, + duplicates: [ + expect.objectContaining({ + groupId: "io.quarkiverse.micrometer.registry", + slug: "io.quarkiverse.micrometer.registry/quarkus-micrometer-registry-datadog", + }), + ], + }) + ) + }) - describe("for an extension with a relocation to another extension", () => { - const extension = { - artifact: - "io.quarkiverse.micrometer.registry:quarkus-micrometer-registry-datadog::jar:2.12.0", - origins: [ - "io.quarkus.platform:quarkus-bom-quarkus-platform-descriptor:3.0.0.Alpha3:json:3.0.0.Alpha3", - ], - } - const olderExtension = { - artifact: - "io.quarkelsewhere:quarkus-micrometer-registry-datacat::jar:3.12.0", - origins: [ - "io.quarkus.platform:quarkus-bom-quarkus-platform-descriptor:3.0.0.Alpha3:json:3.0.0.Alpha3", - ] - } - - // A cut down version of what the registry returns us, with just the relevant bits - const currentPlatforms = { - platforms: [{}], - } - beforeAll(async () => { - axios.get = jest.fn().mockReturnValue({ - data: { - extensions: [extension, olderExtension], - platforms: currentPlatforms.platforms, - }, + it("marks the older duplicate as older", () => { + expect(createNode).toHaveBeenCalledWith( + expect.objectContaining({ + artifact: extension.artifact, + duplicates: [ + expect.objectContaining({ + relationship: "older", + }), + ], + }) + ) }) - await sourceNodes({ actions, createNodeId, createContentDigest }) - }) + it("adds data to the older one explaining what is different", () => { + expect(createNode).toHaveBeenCalledWith( + expect.objectContaining({ + artifact: extension.artifact, + duplicates: [ + expect.objectContaining({ + differenceReason: "artifact id", + differentId: "quarkus-micrometer-registry-datacat" + }), + ], + }) + ) + }) - afterAll(() => { - jest.clearAllMocks() - }) + it("marks the newer duplicate as newer", () => { + expect(createNode).toHaveBeenCalledWith( + expect.objectContaining({ + artifact: olderExtension.artifact, + duplicates: [ + expect.objectContaining({ + relationship: "newer", + }), + ], + }) + ) + }) - it("creates ids", () => { - // It's not easy to do a 'greater than' here, so just hardcode - expect(createNodeId).toHaveBeenCalledTimes(2) - }) + it("adds data to the newer one explaining what is different", () => { + expect(createNode).toHaveBeenCalledWith( + expect.objectContaining({ + artifact: olderExtension.artifact, + duplicates: [ + expect.objectContaining({ + differenceReason: "artifact id", + differentId: "quarkus-micrometer-registry-datadog" + }), + ], + }) + ) + }) - it("adds a link to the newer extension from the older one", () => { - expect(createNode).toHaveBeenCalledWith( - expect.objectContaining({ - artifact: extension.artifact, - duplicates: [ - expect.objectContaining({ - groupId: "io.quarkelsewhere", - slug: "io.quarkelsewhere/quarkus-micrometer-registry-datacat", - }), - ], - }) - ) - }) + it("does not mark the newer extension as superseded", () => { + expect(createNode).not.toHaveBeenCalledWith( + expect.objectContaining({ + artifact: extension.artifact, + isSuperseded: true, + }) + ) + }) - it("adds a link to the older extension from the new one", () => { - expect(createNode).toHaveBeenCalledWith( - expect.objectContaining({ - artifact: olderExtension.artifact, - duplicates: [ - expect.objectContaining({ - groupId: "io.quarkiverse.micrometer.registry", - slug: "io.quarkiverse.micrometer.registry/quarkus-micrometer-registry-datadog", - }), - ], - }) - ) + it("marks the older extension as superseded", () => { + expect(createNode).toHaveBeenCalledWith( + expect.objectContaining({ + artifact: olderExtension.artifact, + isSuperseded: true, + }) + ) + }) }) + }) - it("marks the older duplicate as older", () => { - expect(createNode).toHaveBeenCalledWith( - expect.objectContaining({ - artifact: extension.artifact, - duplicates: [ - expect.objectContaining({ - relationship: "older", - }), - ], - }) - ) + describe("creating pages", () => { + const slug = "sluggy" + beforeAll(async () => { + + const reporter = () => { + } + let graphql = jest.fn().mockResolvedValue({ + data: { + allExtension: { + nodes: [{ + slug, + metadata: { maven: { sinceMonth: "1736766914000" } } + }, { + slug, + metadata: { maven: { sinceMonth: "1626566914000" } } + }] + } + } + }) + await createPages({ graphql, actions, reporter }) }) - it("adds data to the older one explaining what is different", () => { - expect(createNode).toHaveBeenCalledWith( - expect.objectContaining({ - artifact: extension.artifact, - duplicates: [ - expect.objectContaining({ - differenceReason: "artifact id", - differentId: "quarkus-micrometer-registry-datacat" - }), - ], - }) + it("creates pages for extensions", () => { + expect(createPage).toHaveBeenCalledWith( + expect.objectContaining({ path: slug }) ) }) - it("marks the newer duplicate as newer", () => { - expect(createNode).toHaveBeenCalledWith( - expect.objectContaining({ - artifact: olderExtension.artifact, - duplicates: [ - expect.objectContaining({ - relationship: "newer", - }), - ], - }) + it("creates pages for release months", () => { + expect(createPage).toHaveBeenCalledWith( + expect.objectContaining({ path: "extensions-added-january-2025" }) ) }) - it("adds data to the newer one explaining what is different", () => { - expect(createNode).toHaveBeenCalledWith( - expect.objectContaining({ - artifact: olderExtension.artifact, - duplicates: [ - expect.objectContaining({ - differenceReason: "artifact id", - differentId: "quarkus-micrometer-registry-datadog" - }), - ], - }) + it("creates multiple pages for each release months", () => { + expect(createPage).toHaveBeenCalledWith( + expect.objectContaining({ path: "extensions-added-july-2021" }) ) }) - it("does not mark the newer extension as superseded", () => { - expect(createNode).not.toHaveBeenCalledWith( + it("passes through a previous and next", () => { + expect(createPage).toHaveBeenCalledWith( expect.objectContaining({ - artifact: extension.artifact, - isSuperseded: true, + context: { nextMonthTimestamp: "1736766914000", sinceMonth: "1626566914000" }, }) ) - }) - it("marks the older extension as superseded", () => { - expect(createNode).toHaveBeenCalledWith( + expect(createPage).toHaveBeenCalledWith( expect.objectContaining({ - artifact: olderExtension.artifact, - isSuperseded: true, + context: { previousMonthTimestamp: "1626566914000", sinceMonth: "1736766914000" }, }) ) }) diff --git a/plugins/github-enricher/gatsby-node.js b/plugins/github-enricher/gatsby-node.js index 954b3f8b2ea4..a05270cc2f32 100644 --- a/plugins/github-enricher/gatsby-node.js +++ b/plugins/github-enricher/gatsby-node.js @@ -894,6 +894,7 @@ exports.createSchemaCustomization = ({ actions }) => { extensionYamlUrl: String extensionRootUrl: String issues: String + issuesUrl: String samplesUrl: [SampleInfo] lastUpdated: String contributors: [ContributorInfo] diff --git a/src/components/extension-card.js b/src/components/extension-card.js index 358d427b67a0..602c95484a48 100644 --- a/src/components/extension-card.js +++ b/src/components/extension-card.js @@ -14,7 +14,7 @@ const Card = styled(props => )` background: var(--card-background-color) 0 0 no-repeat padding-box; color: var(--main-text-color); border: ${props => - props.$unlisted || props.$superseded ? "1px solid var(--unlisted-outline-color)" : "1px solid var(--card-outline)"}; + props.$unlisted || props.$superseded ? "1px solid var(--unlisted-outline-color)" : "1px solid var(--card-outline)"}; border-radius: 10px; opacity: 1; display: flex; @@ -118,7 +118,7 @@ const ExtensionCard = ({ extension }) => { const superseded = extension.isSuperseded return ( - + {extension.name} diff --git a/src/components/sortings/sortings.js b/src/components/sortings/sortings.js index 1882df852211..e66c7fe04f17 100644 --- a/src/components/sortings/sortings.js +++ b/src/components/sortings/sortings.js @@ -93,7 +93,6 @@ const Sortings = ({ sorterAction, downloadData }) => { const onChange = entry => { if (entry.value !== sort) { - console.log("setting sort") setSort(entry.value) applySort(entry) } diff --git a/src/components/util/extension-slugger.js b/src/components/util/extension-slugger.js index f8749a2d1f45..d7076574a484 100644 --- a/src/components/util/extension-slugger.js +++ b/src/components/util/extension-slugger.js @@ -1,6 +1,8 @@ const parse = require("mvn-artifact-name-parser").default const slugify = require("slugify") +const dateFormatOptions = { year: "numeric", month: "long", timeZone: "Europe/London" } + const slugifyPart = string => { return slugify(string, { lower: true, @@ -28,4 +30,9 @@ const extensionSlugFromCoordinates = coordinates => { } -module.exports = { extensionSlug, extensionSlugFromCoordinates } +function slugForExtensionsAddedMonth(month) { + const date = new Date(+month) + return "extensions-added-" + date.toLocaleDateString("en-US", dateFormatOptions).toLowerCase().replaceAll(" ", "-") +} + +module.exports = { extensionSlug, extensionSlugFromCoordinates, slugForExtensionsAddedMonth } diff --git a/src/maven/maven-info.js b/src/maven/maven-info.js index 8c7f5fd53443..cde35c7de995 100644 --- a/src/maven/maven-info.js +++ b/src/maven/maven-info.js @@ -9,6 +9,7 @@ const promiseRetry = require("promise-retry") const { readPom } = require("./pom-reader") const PersistableCache = require("../persistable-cache") const xml2js = require("xml2js") +const compareVersion = require("compare-version") const parser = new xml2js.Parser({ explicitArray: false, trim: true }) const DAY_IN_SECONDS = 60 * 60 * 24 @@ -37,6 +38,10 @@ const maxios = { } } +const compare = (a, b) => { + return compareVersion(a, b) +} + const initMavenCache = async () => { // If there are problems with the cache, it works well to add something like pomCache.flushAll() on a main-branch build // (and then remove it next build) @@ -150,7 +155,9 @@ const getEarliestVersionFromMavenMetadata = async (groupId, artifactId) => { const versionArrayOrString = raw?.metadata?.versioning?.versions?.version // Maven metadata could return us an array, or, if there's only one version, it gives us a string + if (Array.isArray(versionArrayOrString)) { + versionArrayOrString.sort(compare) return versionArrayOrString[0] } else { return versionArrayOrString @@ -277,20 +284,29 @@ const generateMavenInfo = async artifact => { let since = await timestampCache.getOrSet(earliestArtifact, async () => { // This will be slow because we need to need hit the endpoint too fast and we need to back off; we perhaps should batch, but that's hard to implement with our current model - let thing + let answer try { - thing = await getTimestampFromMavenArtifactsListing(earliestCoordinates) + answer = await getTimestampFromMavenArtifactsListing(earliestCoordinates) } catch (e) { console.log( "Could not get timestamp from repository folder, querying maven directly." ) - thing = await tolerantlyGetTimestampFromMavenSearch(earliestCoordinates) + answer = await tolerantlyGetTimestampFromMavenSearch(earliestCoordinates) } - return thing + return answer }) maven.since = await since - + if (since) { + const trimmed = new Date(since) + // Set the day of the month to 1 because days are 1-indexed + trimmed.setUTCDate(1) // Days of the month, despite the unexpected name + trimmed.setUTCHours(6) // Add some hours to the hours so daylight savings doesn't knock the month back into the previous one + trimmed.setUTCMinutes(0) + trimmed.setUTCSeconds(0) + trimmed.setUTCMilliseconds(0) + maven.sinceMonth = trimmed.valueOf() + } return maven } diff --git a/src/templates/extensions-added-list.js b/src/templates/extensions-added-list.js new file mode 100644 index 000000000000..9b236d3a33c8 --- /dev/null +++ b/src/templates/extensions-added-list.js @@ -0,0 +1,259 @@ +import * as React from "react" +import { useState } from "react" + +import styled from "styled-components" + +import { useMediaQuery } from "react-responsive" +import { device } from "../components/util/styles/breakpoints" +import ExtensionCard from "../components/extension-card" +import { graphql } from "gatsby" +import BreadcrumbBar from "../components/extensions-display/breadcrumb-bar" +import Layout from "../components/layout" +import Sortings from "../components/sortings/sortings" +import { slugForExtensionsAddedMonth } from "../components/util/extension-slugger" +import Link from "gatsby-link" + +const FilterableList = styled.div` + display: flex; + justify-content: space-between; + flex-direction: row; + + // noinspection CssUnknownProperty + @media ${device.sm} { + flex-direction: column; + } +` + +const Extensions = styled.ol` + list-style: none; + width: 100%; + padding-inline-start: 0; + grid-template-rows: repeat(auto-fill, 1fr); + display: grid; + grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); + gap: 30px; + + // noinspection CssUnknownProperty + @media ${device.xs} { + display: flex; + flex-direction: column; + } +` + +const CardItem = styled.li` + height: 100%; + width: 100%; + display: flex; + max-height: 34rem; +` + +const InfoSortRow = styled.div` + display: flex; + column-gap: var(--a-generous-space); + justify-content: space-between; + flex-direction: row; + + // noinspection CssUnknownProperty + @media ${device.sm} { + flex-direction: column; + margin-top: 0; + } +` + +const Heading = styled.h1` + font-size: 3rem; + @media screen and (max-width: 768px) { + font-size: 2rem; + } + line-height: 3.75rem; + font-weight: var(--font-weight-boldest); + margin: 2.5rem 0 1.5rem 0; + padding-bottom: var(--a-modest-space); + width: calc(100vw - 2 * var(--site-margins)); + // noinspection CssUnknownProperty + @media ${device.xs} { + border-bottom: 1px solid var(--card-outline); + } +` + +const ExtensionCount = styled.h4` + margin-top: 1.25rem; + margin-bottom: 0.5rem; + font-size: 1rem; + font-weight: 400; + font-style: italic; + + // noinspection CssUnknownProperty + @media ${device.sm} { + font-size: var(--font-size-14); + } +` + + +const ExtensionsAdded = styled.main` + margin-left: var(--site-margins); + margin-right: var(--site-margins); + + display: flex; + flex-direction: column; +` + +const options = { year: "numeric", month: "long", timeZone: "Europe/London" } + +const prettyDate = (timestamp) => new Date(+timestamp).toLocaleDateString("en-US", options) + + +const ExtensionsAddedListTemplate = ( + { + data: { + allExtension, downloadDataDate, + }, + pageContext: { nextMonthTimestamp, previousMonthTimestamp }, + location, + }) => { + const downloadData = downloadDataDate + + // Convert the data to the same format as what the other list page uses + const { edges } = allExtension + const extensions = edges.map(e => e.node) + + const [extensionComparator, setExtensionComparator] = useState(() => undefined) + + const isMobile = useMediaQuery({ query: device.sm }) + + const nav = () + + if (extensions && extensions.length > 0) { + + // Exclude unlisted and superseded extensions from the count, even though we sometimes show them if there's a direct search for it + const extensionCount = extensions.filter( + extension => !(extension.metadata.unlisted || extension.isSuperseded) + ).length + + if (extensionComparator) { + extensions.sort(extensionComparator) + } + + const countMessage = `${extensionCount} new extensions were added this month.` + const formattedMonth = prettyDate(extensions[0].metadata.maven.sinceMonth) + + const name = `Extensions added in ${formattedMonth}` + + return ( + + + + + New extensions added in {formattedMonth.replaceAll(" ", ", ")} + + {countMessage} + {isMobile || } + + + + {extensions.map(extension => { + return ( + + + + ) + })} + {" "} + + {nav} + + + + ) + } else { + return ( +
+ No new extensions were added this month. + {nav} +
+ ) + } +} + +export default ExtensionsAddedListTemplate + +export const pageQuery = graphql` + query BlogPostByMonthAdded( + $sinceMonth: String! + ) { + allExtension( + filter: {metadata: {maven: {sinceMonth: {glob: $sinceMonth }}}}) { + edges { + node { + name + sortableName + slug + description + artifact + metadata { + status + categories + icon { + childImageSharp { + gatsbyImageData(width: 208) + } + publicURL + } + maven { + version + groupId + artifactId + timestamp + sinceMonth + } + sourceControl { + lastUpdated + projectImage { + childImageSharp { + gatsbyImageData(width: 208) + } + } + extensionRootUrl + ownerImage { + childImageSharp { + gatsbyImageData(width: 208) + } + } + } + } + + isSuperseded + } + } + } + + downloadDataDate( id: {regex: "/.*/g"}) { + date + } + } +` diff --git a/src/templates/extensions-added-list.test.js b/src/templates/extensions-added-list.test.js new file mode 100644 index 000000000000..bba346751530 --- /dev/null +++ b/src/templates/extensions-added-list.test.js @@ -0,0 +1,114 @@ +import React from "react" +import { render, screen } from "@testing-library/react" +import ExtensionsAddedListTemplate from "./extensions-added-list" + + +jest.mock("react-use-query-param-string", () => { + const original = jest.requireActual("react-use-query-param-string") + return { + ...original, + useQueryParamString: jest.fn().mockReturnValue([]), + getQueryParams: jest.fn() + } +}) + +describe("extensions added page", () => { + const category = "jewellery" + const otherCategory = "snails" + + const ruby = { + name: "JRuby", + id: "jruby", + sortableName: "ruby", + slug: "jruby-slug", + metadata: { categories: [category], }, + platforms: ["bottom of the garden"], + } + const diamond = { + name: "JDiamond", + id: "jdiamond", + sortableName: "diamond", + slug: "jdiamond-slug", + metadata: { categories: [category] }, + platforms: ["a mine"], + } + + const molluscs = { + name: "Molluscs", + id: "molluscs", + sortableName: "mollusc", + slug: "molluscs-slug", + metadata: { categories: [otherCategory] }, + platforms: ["bottom of the garden"], + } + + const obsolete = { + name: "Obsolete", + id: "really-old", + sortableName: "old", + slug: "old-slug", + metadata: { categories: [otherCategory] }, + platforms: ["bottom of the garden"], + duplicates: [{ relationship: "newer", groupId: "whatever" }], + isSuperseded: true, + } + + const maybeObsolete = { + name: "Maybebsolete", + id: "maybe-old", + artifact: "maybe-old-or-not", + sortableName: "maybe-old", + slug: "ambiguous-slug", + metadata: { categories: [otherCategory] }, + platforms: ["bottom of the garden"], + duplicates: [{ relationship: "different", groupId: "whatever" }], + } + + const extensions = [ruby, diamond, molluscs, obsolete, maybeObsolete] + const graphQledExtensions = extensions.map(e => { + e.metadata = { maven: { sinceMonth: "1585720800000" } } + return { + node: e + } + }) + + beforeEach(async () => { + + render() + }) + + it("renders the extension name", () => { + expect(screen.getByText(extensions[0].name)).toBeTruthy() + }) + + it("renders the correct link", () => { + const links = screen.getAllByRole("link") + const link = links[links.length - 6]// Look at the last one that's not in the footer, because the top of the page will have a menu and the bottom will have footers - this is also testing the sorting + expect(link).toBeTruthy() + // Hardcoding the host is a bit risky but this should always be true in test environment + expect(link.href).toBe("http://localhost/ambiguous-slug") + }) + + it("displays a brief message about how many extensions there are", async () => { + const num = extensions.length + expect(screen.getByText(new RegExp(`${num -1} new extensions were added`))).toBeTruthy() + }) + + it("displays some text about when the extensions were released", async () => { + expect(screen.getAllByText(/April 2020/)).toBeTruthy() + }) + + it("displays a next and previous links", async () => { + expect(screen.getAllByText(/January 1970/)).toBeTruthy() + expect(screen.getAllByText(/June 1985/)).toBeTruthy() + + }) + +})