Skip to content
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
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -25,5 +25,5 @@ jobs:
- run: npm run lint
- run: npm run format:check
- run: npm run typecheck
- run: npm test
- run: npm run test:coverage
- run: npm run build
2 changes: 1 addition & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ jobs:
- run: npm run lint
- run: npm run format:check
- run: npm run typecheck
- run: npm test
- run: npm run test:coverage
- run: npm run build
- uses: changesets/action@v1
with:
Expand Down
9 changes: 5 additions & 4 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,11 @@ npx playwright install chromium # one-time, for browser-mode tests
Common commands:

```bash
npm run lint # eslint
npm run typecheck # type-check only (noEmit via tsconfig)
npm test # vitest run (browser mode, chromium)
npm run build # tsdown → dist/
npm run lint # eslint
npm run typecheck # type-check only (noEmit via tsconfig)
npm test # vitest run (browser mode, chromium)
npm run test:coverage # vitest run with V8 coverage + threshold gate
npm run build # tsdown → dist/
```

## Pull requests
Expand Down
1,082 changes: 452 additions & 630 deletions package-lock.json

Large diffs are not rendered by default.

14 changes: 8 additions & 6 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@
"format": "prettier --write .",
"format:check": "prettier --check .",
"test": "vitest run",
"test:coverage": "vitest run --coverage",
"typecheck": "tsc",
"prepublishOnly": "npm run build",
"changeset": "changeset",
Expand All @@ -64,19 +65,20 @@
"@eslint/js": "^10.0.1",
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
"@vitest/browser-playwright": "^4.1.9",
"eslint": "^10.5.0",
"@vitest/browser-playwright": "^4.1.10",
"@vitest/coverage-v8": "^4.1.10",
"eslint": "^10.6.0",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-react-hooks": "^7.1.1",
"globals": "^17.6.0",
"prettier": "^3.8.4",
"globals": "^17.7.0",
"prettier": "^3.9.4",
"publint": "^0.3.21",
"react": "^19.2.7",
"react-dom": "^19.2.7",
"tsdown": "^0.22.3",
"typescript": "~6.0.3",
"typescript-eslint": "^8.61.1",
"vitest": "^4.1.9",
"typescript-eslint": "^8.63.0",
"vitest": "^4.1.10",
"vitest-browser-react": "^2.2.0"
},
"publishConfig": {
Expand Down
3 changes: 1 addition & 2 deletions src/internal/lifecycle/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,5 @@ export function getNamespace<Options, Model extends DestroyableModel>(
globalName: BuiltInAIName,
): AINamespace<Options, Model> | undefined {
return (globalThis as Record<string, unknown>)[globalName] as
| AINamespace<Options, Model>
| undefined;
AINamespace<Options, Model> | undefined;
}
17 changes: 13 additions & 4 deletions src/internal/signal.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,15 +83,25 @@ describe("raceAbort", () => {
);
});

test("passes a non-Error rejection through unchanged rather than masking it as AbortError", async () => {
const { signal } = new AbortController();
// A thrown string is a genuine failure, not a cancellation: it must reach
// the caller verbatim, not be rewrapped as an AbortError DOMException.
// Rejecting with a non-Error is the whole point of the test, so the
// prefer-promise-reject-errors lint rule is deliberately suppressed here.
// eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors
const rejected = Promise.reject("boom");
await expect(raceAbort(rejected, signal)).rejects.toBe("boom");
});

test("aborts its cleanup signal once the promise resolves", async () => {
const { signal } = new AbortController();
const addSpy = vi.spyOn(signal, "addEventListener");

await raceAbort(Promise.resolve("ok"), signal);

const options = addSpy.mock.calls[0]?.[2] as
| AddEventListenerOptions
| undefined;
AddEventListenerOptions | undefined;
expect(options?.signal?.aborted).toBe(true);
});

Expand All @@ -103,8 +113,7 @@ describe("raceAbort", () => {
await expect(raceAbort(Promise.reject(inner), signal)).rejects.toBe(inner);

const options = addSpy.mock.calls[0]?.[2] as
| AddEventListenerOptions
| undefined;
AddEventListenerOptions | undefined;
expect(options?.signal?.aborted).toBe(true);
});
});
9 changes: 8 additions & 1 deletion src/internal/signal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,13 +40,20 @@ export function raceAbort<T>(
},
(error: unknown) => {
cleanup.abort();
reject(toError(error));
// The promise's own rejection is a genuine failure, not a cancellation:
// pass it through verbatim (mirroring the resolve path above), so a
// thrown non-Error isn't misclassified as an AbortError. Only the abort
// paths route through toError, where coercing to AbortError is correct.
reject(error);
},
);

return result;
}

// Coerces an abort *reason* to an Error, defaulting a non-Error reason to an
// AbortError — the right classification for a cancellation. Only called with
// `signal.reason`; a promise's own rejection passes through unwrapped.
function toError(reason: unknown): Error {
return reason instanceof Error ? reason : abortError(reason);
}
35 changes: 35 additions & 0 deletions src/language-detector/use-language-detector.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { describe, expect, test, vi } from "vitest";
import { renderHook } from "vitest-browser-react";
import { buildAIFake } from "../internal/testing/ai-namespace-fake";
import { buildLanguageDetectorInstance } from "../internal/testing/instance-fakes";
import { createLanguageDetector } from "./create-language-detector";
import { useLanguageDetector } from "./use-language-detector";

describe("useLanguageDetector", () => {
Expand Down Expand Up @@ -37,6 +38,22 @@ describe("useLanguageDetector", () => {
]);
});

test("measureInput() forwards input to the instance", async () => {
const { Fake, instances } = buildAIFake({
buildInstance: buildLanguageDetectorInstance,
});
vi.stubGlobal("LanguageDetector", Fake);

const { result } = await renderHook(() => useLanguageDetector());
await vi.waitFor(() => expect(result.current.status).toBe("ready"));

await expect(result.current.measureInput("hi")).resolves.toBe(3);
expect(instances[0].measureInputUsage).toHaveBeenCalledWith(
"hi",
expect.anything(),
);
});

test("exposes no streaming variant (compile-time)", () => {
// Arrow is never invoked — runtime skipped; tsc still type-checks the access.
void (() => {
Expand All @@ -46,3 +63,21 @@ describe("useLanguageDetector", () => {
});
});
});

describe("createLanguageDetector", () => {
test("resolves an AsyncDisposable instance delegating to the namespace", async () => {
const { Fake, instances } = buildAIFake({
buildInstance: buildLanguageDetectorInstance,
});
vi.stubGlobal("LanguageDetector", Fake);

const detector = await createLanguageDetector();

expect(detector).toBe(instances[0]);
expect(
(detector as unknown as Record<symbol, unknown>)[Symbol.asyncDispose],
).toBeTypeOf("function");
const [top] = await detector.detect("hello");
expect(top?.detectedLanguage).toBe("en");
});
});
19 changes: 19 additions & 0 deletions src/proofreader/use-proofreader.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { describe, expect, test, vi } from "vitest";
import { renderHook } from "vitest-browser-react";
import { buildAIFake } from "../internal/testing/ai-namespace-fake";
import { buildProofreaderInstance } from "../internal/testing/instance-fakes";
import { createProofreader } from "./create-proofreader";
import { useProofreader } from "./use-proofreader";

describe("useProofreader", () => {
Expand Down Expand Up @@ -34,3 +35,21 @@ describe("useProofreader", () => {
});
});
});

describe("createProofreader", () => {
test("resolves an AsyncDisposable instance delegating to the namespace", async () => {
const { Fake, instances } = buildAIFake({
buildInstance: buildProofreaderInstance,
});
vi.stubGlobal("Proofreader", Fake);

const proofreader = await createProofreader();

expect(proofreader).toBe(instances[0]);
expect(
(proofreader as unknown as Record<symbol, unknown>)[Symbol.asyncDispose],
).toBeTypeOf("function");
const result = await proofreader.proofread("helo");
expect(result.correctedInput).toBe("corrected(helo)");
});
});
18 changes: 18 additions & 0 deletions src/rewriter/use-rewriter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { describe, expect, test, vi } from "vitest";
import { renderHook } from "vitest-browser-react";
import { buildAIFake } from "../internal/testing/ai-namespace-fake";
import { buildRewriterInstance } from "../internal/testing/instance-fakes";
import { createRewriter } from "./create-rewriter";
import { useRewriter } from "./use-rewriter";

describe("useRewriter", () => {
Expand Down Expand Up @@ -61,3 +62,20 @@ describe("useRewriter", () => {
);
});
});

describe("createRewriter", () => {
test("resolves an AsyncDisposable instance delegating to the namespace", async () => {
const { Fake, instances } = buildAIFake({
buildInstance: buildRewriterInstance,
});
vi.stubGlobal("Rewriter", Fake);

const rewriter = await createRewriter();

expect(rewriter).toBe(instances[0]);
expect(
(rewriter as unknown as Record<symbol, unknown>)[Symbol.asyncDispose],
).toBeTypeOf("function");
await expect(rewriter.rewrite("draft")).resolves.toBe("R():draft");
});
});
18 changes: 18 additions & 0 deletions src/summarizer/use-summarizer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { describe, expect, test, vi } from "vitest";
import { renderHook } from "vitest-browser-react";
import { buildAIFake } from "../internal/testing/ai-namespace-fake";
import { buildSummarizerInstance } from "../internal/testing/instance-fakes";
import { createSummarizer } from "./create-summarizer";
import { useSummarizer } from "./use-summarizer";

describe("useSummarizer", () => {
Expand Down Expand Up @@ -61,3 +62,20 @@ describe("useSummarizer", () => {
);
});
});

describe("createSummarizer", () => {
test("resolves an AsyncDisposable instance delegating to the namespace", async () => {
const { Fake, instances } = buildAIFake({
buildInstance: buildSummarizerInstance,
});
vi.stubGlobal("Summarizer", Fake);

const summarizer = await createSummarizer();

expect(summarizer).toBe(instances[0]);
expect(
(summarizer as unknown as Record<symbol, unknown>)[Symbol.asyncDispose],
).toBeTypeOf("function");
await expect(summarizer.summarize("text")).resolves.toBe("S():text");
});
});
33 changes: 33 additions & 0 deletions src/translator/use-translator.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { describe, expect, test, vi } from "vitest";
import { renderHook } from "vitest-browser-react";
import { buildAIFake } from "../internal/testing/ai-namespace-fake";
import { buildTranslatorInstance } from "../internal/testing/instance-fakes";
import { createTranslator } from "./create-translator";
import { useTranslator } from "./use-translator";

describe("useTranslator", () => {
Expand Down Expand Up @@ -45,6 +46,18 @@ describe("useTranslator", () => {
expect(chunks).toEqual(["T:", "hello"]);
});

test("measureInput() forwards input to the instance", async () => {
const { Fake } = buildAIFake({ buildInstance: buildTranslatorInstance });
vi.stubGlobal("Translator", Fake);

const { result } = await renderHook(() =>
useTranslator({ sourceLanguage: "en", targetLanguage: "fr" }),
);
await vi.waitFor(() => expect(result.current.status).toBe("ready"));

await expect(result.current.measureInput("hi")).resolves.toBe(7);
});

test("requires a TranslatorOptions argument (compile-time)", () => {
// These arrows are never executed; tsc type-checks the calls statically,
// verifying the options argument is required (both missing and undefined are rejected).
Expand All @@ -54,3 +67,23 @@ describe("useTranslator", () => {
void (() => useTranslator(undefined));
});
});

describe("createTranslator", () => {
test("resolves an AsyncDisposable instance delegating to the namespace", async () => {
const { Fake, instances } = buildAIFake({
buildInstance: buildTranslatorInstance,
});
vi.stubGlobal("Translator", Fake);

const translator = await createTranslator({
sourceLanguage: "en",
targetLanguage: "fr",
});

expect(translator).toBe(instances[0]);
expect(
(translator as unknown as Record<symbol, unknown>)[Symbol.asyncDispose],
).toBeTypeOf("function");
await expect(translator.translate("hi")).resolves.toBe("T:hi");
});
});
18 changes: 18 additions & 0 deletions src/writer/use-writer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { describe, expect, test, vi } from "vitest";
import { renderHook } from "vitest-browser-react";
import { buildAIFake } from "../internal/testing/ai-namespace-fake";
import { buildWriterInstance } from "../internal/testing/instance-fakes";
import { createWriter } from "./create-writer";
import { useWriter } from "./use-writer";

describe("useWriter", () => {
Expand Down Expand Up @@ -59,3 +60,20 @@ describe("useWriter", () => {
);
});
});

describe("createWriter", () => {
test("resolves an AsyncDisposable instance delegating to the namespace", async () => {
const { Fake, instances } = buildAIFake({
buildInstance: buildWriterInstance,
});
vi.stubGlobal("Writer", Fake);

const writer = await createWriter();

expect(writer).toBe(instances[0]);
expect(
(writer as unknown as Record<symbol, unknown>)[Symbol.asyncDispose],
).toBeTypeOf("function");
await expect(writer.write("draft")).resolves.toBe("W():draft");
});
});
23 changes: 22 additions & 1 deletion vitest.config.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { playwright } from "@vitest/browser-playwright";
import { defineConfig } from "vitest/config";
import { coverageConfigDefaults, defineConfig } from "vitest/config";

export default defineConfig({
test: {
Expand All @@ -10,5 +10,26 @@ export default defineConfig({
provider: playwright(),
instances: [{ browser: "chromium" }],
},
coverage: {
include: ["src/**/*.ts"],
exclude: [
...coverageConfigDefaults.exclude,
// Test-only fakes and the pure re-export barrel carry no logic to
// cover; counting them would only dilute the signal.
"src/internal/testing/**",
"src/index.ts",
],
// Mirrors the @shayc/open-board-format sibling package's floors, which
// catch real regressions with margin for V8 branch-counting variance.
// functions is 95 rather than that package's 100 because a few defensive
// `.catch(() => undefined)` handlers here are untestable without
// contrivance; actuals sit ~96 stmts / ~90 branch / ~97 funcs / ~96 lines.
thresholds: {
statements: 95,
branches: 80,
functions: 95,
lines: 95,
},
},
},
});