Skip to content
Open
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
124 changes: 124 additions & 0 deletions src/signals/ai.test.ts
Original file line number Diff line number Diff line change
@@ -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<any>, opera = false) {
const browserWindow: Record<string, unknown> = {};
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();
});
});
32 changes: 28 additions & 4 deletions src/signals/ai.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,25 @@ function isOpera(): boolean {
return typeof (window as any).opr !== 'undefined';
}

const AI_AVAILABILITY_TIMEOUT_MS = 2_500;

function boundedAwait<T>(promise: Promise<T>): Promise<T | typeof ERROR> {
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,
Expand All @@ -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<string>((window as any).Summarizer.availability()),
boundedAwait<string>((window as any).Summarizer.availability({
expectedInputLanguages: [navigator.language],
})),
]);
} catch (error) {
setObjectValues(aiResults, ERROR);
}
Expand Down