diff --git a/src/signals/ai.test.ts b/src/signals/ai.test.ts new file mode 100644 index 0000000..36d1d06 --- /dev/null +++ b/src/signals/ai.test.ts @@ -0,0 +1,124 @@ +import { afterEach, describe, expect, test, vi } from "vitest"; +import { ai } from "./ai"; +import { ERROR, NA } from "./utils"; + +const TIMEOUT_MS = 2_500; + +function setBrowserGlobals(availability?: (...args: any[]) => Promise, opera = false) { + const browserWindow: Record = {}; + if (availability) { + browserWindow.Summarizer = { availability }; + } + if (opera) { + browserWindow.opr = {}; + } + vi.stubGlobal("window", browserWindow); + vi.stubGlobal("navigator", { language: "en-US" }); +} + +afterEach(() => { + vi.useRealTimers(); + vi.unstubAllGlobals(); +}); + +describe("ai", () => { + test("returns NA when Summarizer is absent", async () => { + setBrowserGlobals(); + + await expect(ai()).resolves.toEqual({ + summarizerAvailability: NA, + summarizerLanguageAvailability: NA, + }); + }); + + test("preserves normally resolved values", async () => { + const availability = vi.fn() + .mockResolvedValueOnce("readily") + .mockResolvedValueOnce("after-download"); + setBrowserGlobals(availability); + + await expect(ai()).resolves.toEqual({ + summarizerAvailability: "readily", + summarizerLanguageAvailability: "after-download", + }); + expect(availability).toHaveBeenNthCalledWith(1); + expect(availability).toHaveBeenNthCalledWith(2, { + expectedInputLanguages: ["en-US"], + }); + }); + + test("preserves normal rejection handling", async () => { + const availability = vi.fn() + .mockRejectedValueOnce(new Error("unavailable")) + .mockResolvedValueOnce("readily"); + setBrowserGlobals(availability); + + await expect(ai()).resolves.toEqual({ + summarizerAvailability: ERROR, + summarizerLanguageAvailability: ERROR, + }); + }); + + test("times out when the first availability call never settles", async () => { + vi.useFakeTimers(); + const availability = vi.fn() + .mockReturnValueOnce(new Promise(() => {})) + .mockResolvedValueOnce("readily"); + setBrowserGlobals(availability); + + const result = ai(); + await vi.advanceTimersByTimeAsync(TIMEOUT_MS); + + await expect(result).resolves.toEqual({ + summarizerAvailability: ERROR, + summarizerLanguageAvailability: "readily", + }); + }); + + test("times out when the language availability call never settles", async () => { + vi.useFakeTimers(); + const availability = vi.fn() + .mockResolvedValueOnce("readily") + .mockReturnValueOnce(new Promise(() => {})); + setBrowserGlobals(availability); + + const result = ai(); + await vi.advanceTimersByTimeAsync(TIMEOUT_MS); + + await expect(result).resolves.toEqual({ + summarizerAvailability: "readily", + summarizerLanguageAvailability: ERROR, + }); + }); + + test("always completes within the timeout when both calls remain pending", async () => { + vi.useFakeTimers(); + const availability = vi.fn(() => new Promise(() => {})); + setBrowserGlobals(availability); + let settled = false; + + const result = ai().finally(() => { + settled = true; + }); + await vi.advanceTimersByTimeAsync(TIMEOUT_MS - 1); + expect(settled).toBe(false); + await vi.advanceTimersByTimeAsync(1); + + await expect(result).resolves.toEqual({ + summarizerAvailability: ERROR, + summarizerLanguageAvailability: ERROR, + }); + expect(settled).toBe(true); + }); + + test("keeps the Opera exclusion", async () => { + const availability = vi.fn().mockResolvedValue("readily"); + setBrowserGlobals(availability, true); + + await expect(ai()).resolves.toEqual({ + summarizerAvailability: NA, + summarizerLanguageAvailability: NA, + }); + expect(availability).not.toHaveBeenCalled(); + }); +}); diff --git a/src/signals/ai.ts b/src/signals/ai.ts index 9f45e23..73cbe42 100644 --- a/src/signals/ai.ts +++ b/src/signals/ai.ts @@ -7,6 +7,25 @@ function isOpera(): boolean { return typeof (window as any).opr !== 'undefined'; } +const AI_AVAILABILITY_TIMEOUT_MS = 2_500; + +function boundedAwait(promise: Promise): Promise { + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => resolve(ERROR), AI_AVAILABILITY_TIMEOUT_MS); + + promise.then( + (value) => { + clearTimeout(timeout); + resolve(value); + }, + (error) => { + clearTimeout(timeout); + reject(error); + }, + ); + }); +} + export async function ai() { const aiResults = { summarizerAvailability: INIT, @@ -16,10 +35,15 @@ export async function ai() { // Opera exposes Summarizer but availability() never settles, hanging the entire collection. if ('Summarizer' in window && !isOpera()) { try { - aiResults.summarizerAvailability = await (window as any).Summarizer.availability(); - aiResults.summarizerLanguageAvailability = await (window as any).Summarizer.availability({ - expectedInputLanguages: [navigator.language], - }); + [ + aiResults.summarizerAvailability, + aiResults.summarizerLanguageAvailability, + ] = await Promise.all([ + boundedAwait((window as any).Summarizer.availability()), + boundedAwait((window as any).Summarizer.availability({ + expectedInputLanguages: [navigator.language], + })), + ]); } catch (error) { setObjectValues(aiResults, ERROR); }