diff --git a/apps/nextjs/src/instrumentation.spec.ts b/apps/nextjs/src/instrumentation.spec.ts index c8cb5d586..dc5d6724f 100644 --- a/apps/nextjs/src/instrumentation.spec.ts +++ b/apps/nextjs/src/instrumentation.spec.ts @@ -3,10 +3,15 @@ import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; const registerAsync = async (nodeEnv: string, nextRuntime: string) => { + const loggerLoaded = vi.fn(); const tasksStarted = vi.fn(); const websocketStarted = vi.fn(); vi.stubEnv("NODE_ENV", nodeEnv); vi.stubEnv("NEXT_RUNTIME", nextRuntime); + vi.doMock("@homarr/core/infrastructure/logs", () => { + loggerLoaded(); + return { createLogger: () => ({ error: vi.fn() }) }; + }); vi.doMock("@homarr/tasks", () => { tasksStarted(); return {}; @@ -18,7 +23,7 @@ const registerAsync = async (nodeEnv: string, nextRuntime: string) => { const { register } = await import("./instrumentation"); await register(); - return { tasksStarted, websocketStarted }; + return { loggerLoaded, tasksStarted, websocketStarted }; }; describe("Next instrumentation", () => { @@ -27,28 +32,32 @@ describe("Next instrumentation", () => { }); afterEach(() => { + vi.doUnmock("@homarr/core/infrastructure/logs"); vi.doUnmock("@homarr/tasks"); vi.doUnmock("@homarr/websocket"); vi.unstubAllEnvs(); }); test("embeds background services in the production Node runtime", async () => { - const { tasksStarted, websocketStarted } = await registerAsync("production", "nodejs"); + const { loggerLoaded, tasksStarted, websocketStarted } = await registerAsync("production", "nodejs"); + expect(loggerLoaded).toHaveBeenCalledOnce(); expect(tasksStarted).toHaveBeenCalledOnce(); expect(websocketStarted).toHaveBeenCalledOnce(); }); test("does not embed background services in the development server", async () => { - const { tasksStarted, websocketStarted } = await registerAsync("development", "nodejs"); + const { loggerLoaded, tasksStarted, websocketStarted } = await registerAsync("development", "nodejs"); + expect(loggerLoaded).not.toHaveBeenCalled(); expect(tasksStarted).not.toHaveBeenCalled(); expect(websocketStarted).not.toHaveBeenCalled(); }); test("does not embed Node services in the edge runtime", async () => { - const { tasksStarted, websocketStarted } = await registerAsync("production", "edge"); + const { loggerLoaded, tasksStarted, websocketStarted } = await registerAsync("production", "edge"); + expect(loggerLoaded).not.toHaveBeenCalled(); expect(tasksStarted).not.toHaveBeenCalled(); expect(websocketStarted).not.toHaveBeenCalled(); }); diff --git a/apps/nextjs/src/instrumentation.ts b/apps/nextjs/src/instrumentation.ts index d73053bec..8333ce6f8 100644 --- a/apps/nextjs/src/instrumentation.ts +++ b/apps/nextjs/src/instrumentation.ts @@ -1,11 +1,9 @@ -import { createLogger } from "@homarr/core/infrastructure/logs"; - -const logger = createLogger({ module: "instrumentation" }); - export async function register() { if (process.env.NEXT_RUNTIME !== "nodejs") return; if (process.env.NODE_ENV !== "production") return; + const { createLogger } = await import("@homarr/core/infrastructure/logs"); + const logger = createLogger({ module: "instrumentation" }); const services = [ { name: "tasks", start: () => import("@homarr/tasks") }, { name: "websocket", start: () => import("@homarr/websocket") }, diff --git a/e2e/health-checks.spec.ts b/e2e/health-checks.spec.ts index b280fcc56..411d8e29c 100644 --- a/e2e/health-checks.spec.ts +++ b/e2e/health-checks.spec.ts @@ -8,13 +8,17 @@ describe("Health checks", () => { // Arrange const homarrContainer = await createHomarrContainer().start(); - // Act - const readyResponse = await fetch(`http://localhost:${homarrContainer.getMappedPort(7575)}/api/health/ready`); - const liveResponse = await fetch(`http://localhost:${homarrContainer.getMappedPort(7575)}/api/health/live`); + try { + // Act + const readyResponse = await fetch(`http://localhost:${homarrContainer.getMappedPort(7575)}/api/health/ready`); + const liveResponse = await fetch(`http://localhost:${homarrContainer.getMappedPort(7575)}/api/health/live`); - // Assert - expect(readyResponse.status).toBe(200); - expect(liveResponse.status).toBe(200); + // Assert + expect(readyResponse.status).toBe(200); + expect(liveResponse.status).toBe(200); + } finally { + await homarrContainer.stop(); + } }, 60_000); test("ready and live should return 200 OK with external redis", async () => { @@ -29,18 +33,22 @@ describe("Health checks", () => { }, }).start(); - // Act - const readyResponse = await fetch(`http://localhost:${homarrContainer.getMappedPort(7575)}/api/health/ready`); - const liveResponse = await fetch(`http://localhost:${homarrContainer.getMappedPort(7575)}/api/health/live`); + try { + // Act + const readyResponse = await fetch(`http://localhost:${homarrContainer.getMappedPort(7575)}/api/health/ready`); + const liveResponse = await fetch(`http://localhost:${homarrContainer.getMappedPort(7575)}/api/health/live`); - // Assert - expect( - readyResponse.status, - `Expected ready to return OK statusCode=${readyResponse.status} content=${await readyResponse.text()}`, - ).toBe(200); - expect( - liveResponse.status, - `Expected live to return OK statusCode=${liveResponse.status} content=${await liveResponse.text()}`, - ).toBe(200); + // Assert + expect( + readyResponse.status, + `Expected ready to return OK statusCode=${readyResponse.status} content=${await readyResponse.text()}`, + ).toBe(200); + expect( + liveResponse.status, + `Expected live to return OK statusCode=${liveResponse.status} content=${await liveResponse.text()}`, + ).toBe(200); + } finally { + await Promise.all([homarrContainer.stop(), redisContainer.stop()]); + } }, 60_000); }); diff --git a/e2e/home.spec.ts b/e2e/home.spec.ts index 2d79d7247..dcc8685c9 100644 --- a/e2e/home.spec.ts +++ b/e2e/home.spec.ts @@ -7,10 +7,14 @@ describe("Home", () => { // Arrange const homarrContainer = await createHomarrContainer().start(); - // Act - const homeResponse = await fetch(`http://localhost:${homarrContainer.getMappedPort(7575)}/`); + try { + // Act + const homeResponse = await fetch(`http://localhost:${homarrContainer.getMappedPort(7575)}/`); - // Assert - expect(homeResponse.status).toBe(200); + // Assert + expect(homeResponse.status).toBe(200); + } finally { + await homarrContainer.stop(); + } }, 60_000); }); diff --git a/e2e/lldap.spec.ts b/e2e/lldap.spec.ts index f1f07790d..f1e6aabbc 100644 --- a/e2e/lldap.spec.ts +++ b/e2e/lldap.spec.ts @@ -37,45 +37,45 @@ describe("LLDAP authorization", () => { const context = await browser.newContext(); const page = await context.newPage(); - const onboardingActions = new OnboardingActions(page, db); - await onboardingActions.skipOnboardingAsync({ - group: defaultCredentials.group, - }); + try { + const onboardingActions = new OnboardingActions(page, db); + await onboardingActions.skipOnboardingAsync({ + group: defaultCredentials.group, + }); - // Act - await page.goto(`http://${homarrContainer.getHost()}:${homarrContainer.getMappedPort(7575)}/auth/login`); - await page.getByLabel("Username").fill(defaultCredentials.username); - await page.locator("#password").fill(defaultCredentials.password); - await page.locator("css=button[type='submit']").click(); + // Act + const baseUrl = `http://${homarrContainer.getHost()}:${homarrContainer.getMappedPort(7575)}`; + await page.goto(`${baseUrl}/auth/login`); + await page.getByLabel("Username").fill(defaultCredentials.username); + await page.locator("#password").fill(defaultCredentials.password); + await page.locator("css=button[type='submit']").click(); - // Assert - await page.waitForURL(`http://${homarrContainer.getHost()}:${homarrContainer.getMappedPort(7575)}`); - const users = await db.query.users.findMany({ - with: { - groups: { - with: { - group: true, + // Assert + await page.waitForURL(baseUrl, { timeout: 60_000 }); + const users = await db.query.users.findMany({ + with: { + groups: { + with: { + group: true, + }, }, }, - }, - }); - expect(users).toHaveLength(1); - const user = users[0]!; - expect(user).toEqual( - expect.objectContaining({ - name: defaultCredentials.username, - email: defaultCredentials.email, - provider: "ldap", - }), - ); + }); + expect(users).toHaveLength(1); + const user = users[0]!; + expect(user).toEqual( + expect.objectContaining({ + name: defaultCredentials.username, + email: defaultCredentials.email, + provider: "ldap", + }), + ); - const groups = user.groups.map((g) => g.group.name); - expect(groups).toContain(defaultCredentials.group); - - // Cleanup - await browser.close(); - await homarrContainer.stop(); - await lldapContainer.stop(); + const groups = user.groups.map((g) => g.group.name); + expect(groups).toContain(defaultCredentials.group); + } finally { + await Promise.all([browser.close(), homarrContainer.stop(), lldapContainer.stop()]); + } }, 120_000); }); diff --git a/e2e/onboarding.spec.ts b/e2e/onboarding.spec.ts index a8a09773e..39f473c20 100644 --- a/e2e/onboarding.spec.ts +++ b/e2e/onboarding.spec.ts @@ -22,25 +22,25 @@ describe("Onboarding", () => { const actions = new OnboardingActions(page, db); const assertions = new OnboardingAssertions(page, db); - // Act - await page.goto(`http://${homarrContainer.getHost()}:${homarrContainer.getMappedPort(7575)}`); - await actions.startOnboardingAsync("scratch"); - await actions.processUserStepAsync({ - username: "admin", - password: "Comp(exP4sswOrd", - confirmPassword: "Comp(exP4sswOrd", - }); - await actions.processSettingsStepAsync(); - await actions.processIntegrationsStepAsync(); + try { + // Act + await page.goto(`http://${homarrContainer.getHost()}:${homarrContainer.getMappedPort(7575)}`); + await actions.startOnboardingAsync("scratch"); + await actions.processUserStepAsync({ + username: "admin", + password: "Comp(exP4sswOrd", + confirmPassword: "Comp(exP4sswOrd", + }); + await actions.processSettingsStepAsync(); + await actions.processIntegrationsStepAsync(); - // Assert - await assertions.assertFinishStepVisibleAsync(); - await assertions.assertUserAndAdminGroupInsertedAsync("admin"); - await assertions.assertDbOnboardingStepAsync("finish"); - - // Cleanup - await browser.close(); - await homarrContainer.stop(); + // Assert + await assertions.assertFinishStepVisibleAsync(); + await assertions.assertUserAndAdminGroupInsertedAsync("admin"); + await assertions.assertDbOnboardingStepAsync("finish"); + } finally { + await Promise.all([browser.close(), homarrContainer.stop()]); + } }, 60_000); test("External provider onboarding setup should be successful", async () => { @@ -66,22 +66,22 @@ describe("Onboarding", () => { const actions = new OnboardingActions(page, db); const assertions = new OnboardingAssertions(page, db); - // Act - await page.goto(`http://${homarrContainer.getHost()}:${homarrContainer.getMappedPort(7575)}`); - await actions.startOnboardingAsync("scratch"); - await actions.processExternalGroupStepAsync({ - name: externalGroupName, - }); - await actions.processSettingsStepAsync(); - await actions.processIntegrationsStepAsync(); + try { + // Act + await page.goto(`http://${homarrContainer.getHost()}:${homarrContainer.getMappedPort(7575)}`); + await actions.startOnboardingAsync("scratch"); + await actions.processExternalGroupStepAsync({ + name: externalGroupName, + }); + await actions.processSettingsStepAsync(); + await actions.processIntegrationsStepAsync(); - // Assert - await assertions.assertFinishStepVisibleAsync(); - await assertions.assertExternalGroupInsertedAsync(externalGroupName); - await assertions.assertDbOnboardingStepAsync("finish"); - - // Cleanup - await browser.close(); - await homarrContainer.stop(); + // Assert + await assertions.assertFinishStepVisibleAsync(); + await assertions.assertExternalGroupInsertedAsync(externalGroupName); + await assertions.assertDbOnboardingStepAsync("finish"); + } finally { + await Promise.all([browser.close(), homarrContainer.stop()]); + } }, 60_000); }); diff --git a/e2e/shared/assertions/onboarding-assertions.ts b/e2e/shared/assertions/onboarding-assertions.ts index 12cda67d0..c6cffc29c 100644 --- a/e2e/shared/assertions/onboarding-assertions.ts +++ b/e2e/shared/assertions/onboarding-assertions.ts @@ -57,6 +57,6 @@ export class OnboardingAssertions { } public async assertFinishStepVisibleAsync() { - await this.page.waitForSelector("text=completed the setup", { timeout: 15000 }); + await this.page.waitForSelector("text=completed the setup", { timeout: 30_000 }); } } diff --git a/packages/request-handler/src/test/update-checker-cache.spec.ts b/packages/request-handler/src/test/update-checker-cache.spec.ts index 06264fb4c..4144348ff 100644 --- a/packages/request-handler/src/test/update-checker-cache.spec.ts +++ b/packages/request-handler/src/test/update-checker-cache.spec.ts @@ -122,6 +122,29 @@ describe("persisted update checker cache", () => { expect(mocks.listReleases).toHaveBeenCalledTimes(1); }); + test("deduplicates concurrent checks across simulated processes", async () => { + let completeGitHubRequest: (() => void) | undefined; + mocks.listReleases.mockImplementation( + async () => + await new Promise((resolve) => { + completeGitHubRequest = () => resolve(successfulResponse); + }), + ); + const firstHandler = await loadHandlerAsync(); + vi.resetModules(); + const secondHandler = await loadHandlerAsync(); + + const firstRequest = firstHandler.getDataAsync(); + await vi.waitFor(() => expect(mocks.listReleases).toHaveBeenCalledOnce()); + const secondRequest = secondHandler.getDataAsync(); + completeGitHubRequest?.(); + + const results = await Promise.all([firstRequest, secondRequest]); + + expect(results[0].data).toEqual(results[1].data); + expect(mocks.listReleases).toHaveBeenCalledTimes(1); + }); + test("refreshes at the 24-hour boundary, not before it", async () => { let now = 1_000; vi.spyOn(Date, "now").mockImplementation(() => now); @@ -150,18 +173,27 @@ describe("persisted update checker cache", () => { expect(mocks.listReleases).toHaveBeenCalledTimes(1); }); - test("serves stale data and suppresses retries for 24 hours after a provider failure", async () => { + test("serves stale data and retries only after 24 hours following a provider failure", async () => { + let now = 1_000; + vi.spyOn(Date, "now").mockImplementation(() => now); mocks.listReleases.mockResolvedValueOnce(successfulResponse).mockRejectedValue(new Error("GitHub unavailable")); const handler = await loadHandlerAsync(); const fresh = await handler.getDataAsync(); mocks.removeFreshResult(); const stale = await handler.getDataAsync(); + now += 24 * 60 * 60 * 1_000 - 1; const repeated = await handler.getDataAsync(); expect(stale.data).toEqual(fresh.data); expect(repeated.data).toEqual(fresh.data); expect(mocks.listReleases).toHaveBeenCalledTimes(2); + + now += 1; + const retried = await handler.getDataAsync(); + + expect(retried.data).toEqual(fresh.data); + expect(mocks.listReleases).toHaveBeenCalledTimes(3); }); test("does not contact GitHub when external connections are disabled", async () => {