Archived
test: close selected coverage gaps
This commit is contained in:
@@ -1,13 +1,21 @@
|
||||
import { mkdir, mkdtemp, readFile, readdir, rm, symlink } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { DEFAULT_ATTACHMENT_FOLDER, saveAttachmentsToWorkspace } from "./attachmentService.js";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { formatDimensionNote, resizeImage, type ResizedImage } from "@earendil-works/pi-coding-agent";
|
||||
import { DEFAULT_ATTACHMENT_FOLDER, attachmentsToInlineImages, saveAttachmentsToWorkspace } from "./attachmentService.js";
|
||||
|
||||
vi.mock("@earendil-works/pi-coding-agent", () => ({
|
||||
formatDimensionNote: vi.fn(),
|
||||
resizeImage: vi.fn(),
|
||||
}));
|
||||
|
||||
let workspace: string;
|
||||
let externalDirectories: string[] = [];
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.mocked(formatDimensionNote).mockReset();
|
||||
vi.mocked(resizeImage).mockReset();
|
||||
workspace = await mkdtemp(join(tmpdir(), "pi-web-attachments-"));
|
||||
externalDirectories = [];
|
||||
});
|
||||
@@ -22,6 +30,63 @@ afterEach(async () => {
|
||||
const pngBytes = Buffer.from([0x89, 0x50, 0x4e, 0x47]);
|
||||
const pngBase64 = pngBytes.toString("base64");
|
||||
|
||||
function resizedImage(overrides: Partial<ResizedImage> = {}): ResizedImage {
|
||||
return {
|
||||
data: "resized-data",
|
||||
mimeType: "image/png",
|
||||
originalWidth: 2400,
|
||||
originalHeight: 1200,
|
||||
width: 1200,
|
||||
height: 600,
|
||||
wasResized: true,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("attachmentsToInlineImages", () => {
|
||||
it("resizes images, drops unresizable images, and preserves dimension notes", async () => {
|
||||
const firstInput = Buffer.from("first image");
|
||||
const droppedInput = Buffer.from("too large");
|
||||
const thirdInput = Buffer.from("third image");
|
||||
const firstResized = resizedImage({ data: "first-resized", mimeType: "image/webp" });
|
||||
const thirdResized = resizedImage({
|
||||
data: "third-resized",
|
||||
mimeType: "image/jpeg",
|
||||
originalWidth: 640,
|
||||
originalHeight: 480,
|
||||
width: 640,
|
||||
height: 480,
|
||||
wasResized: false,
|
||||
});
|
||||
|
||||
vi.mocked(resizeImage)
|
||||
.mockResolvedValueOnce(firstResized)
|
||||
.mockResolvedValueOnce(null)
|
||||
.mockResolvedValueOnce(thirdResized);
|
||||
vi.mocked(formatDimensionNote)
|
||||
.mockReturnValueOnce("[Image dimensions changed.]")
|
||||
.mockReturnValueOnce(undefined);
|
||||
|
||||
await expect(attachmentsToInlineImages([
|
||||
{ kind: "image", mimeType: "image/png", data: firstInput.toString("base64"), name: "first.png" },
|
||||
{ kind: "image", mimeType: "image/png", data: droppedInput.toString("base64"), name: "huge.png" },
|
||||
{ kind: "image", mimeType: "image/jpeg", data: thirdInput.toString("base64"), name: "photo.jpg" },
|
||||
])).resolves.toEqual([
|
||||
{
|
||||
image: { type: "image", data: "first-resized", mimeType: "image/webp" },
|
||||
dimensionNote: "[Image dimensions changed.]",
|
||||
},
|
||||
{ image: { type: "image", data: "third-resized", mimeType: "image/jpeg" } },
|
||||
]);
|
||||
|
||||
expect(resizeImage).toHaveBeenNthCalledWith(1, firstInput, "image/png");
|
||||
expect(resizeImage).toHaveBeenNthCalledWith(2, droppedInput, "image/png");
|
||||
expect(resizeImage).toHaveBeenNthCalledWith(3, thirdInput, "image/jpeg");
|
||||
expect(formatDimensionNote).toHaveBeenNthCalledWith(1, firstResized);
|
||||
expect(formatDimensionNote).toHaveBeenNthCalledWith(2, thirdResized);
|
||||
});
|
||||
});
|
||||
|
||||
describe("saveAttachmentsToWorkspace", () => {
|
||||
it("writes attachments into the default folder and returns relative paths", async () => {
|
||||
const fixedNow = () => new Date("2026-06-13T12:05:01.123Z");
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { AuthStorage, ModelRegistry } from "@earendil-works/pi-coding-agent";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { OAuthFlowState } from "../../shared/apiTypes.js";
|
||||
import { AuthService, type AuthChange } from "./authService.js";
|
||||
import { OAuthLoginFlowService } from "./oauthLoginFlowService.js";
|
||||
|
||||
describe("AuthService", () => {
|
||||
it("saves API keys and emits a global auth change", () => {
|
||||
@@ -30,6 +32,39 @@ describe("AuthService", () => {
|
||||
expect(changes).toEqual([]);
|
||||
auth.dispose();
|
||||
});
|
||||
|
||||
it("refreshes auth state after OAuth login completes", () => {
|
||||
const authStorage = AuthStorage.inMemory();
|
||||
const modelRegistry = ModelRegistry.create(authStorage);
|
||||
const authFlows = new CapturingOAuthLoginFlowService();
|
||||
const auth = new AuthService({ modelRegistry, authFlows });
|
||||
const changes: AuthChange[] = [];
|
||||
auth.subscribe((change) => { changes.push(change); });
|
||||
const reload = vi.spyOn(authStorage, "reload");
|
||||
const refresh = vi.spyOn(modelRegistry, "refresh");
|
||||
const provider = authStorage.getOAuthProviders().find((option) => option.id === "anthropic");
|
||||
if (provider === undefined) throw new Error("Expected built-in OAuth provider");
|
||||
|
||||
expect(auth.startOAuthLogin(provider.id)).toMatchObject({ providerId: provider.id, providerName: provider.name, status: "running" });
|
||||
|
||||
const startOptions = authFlows.startCalls.at(0);
|
||||
if (startOptions === undefined) throw new Error("Expected OAuth flow to start");
|
||||
expect(startOptions.providerId).toBe(provider.id);
|
||||
expect(startOptions.providerName).toBe(provider.name);
|
||||
expect(startOptions.authStorage).toBe(authStorage);
|
||||
expect(changes).toEqual([]);
|
||||
|
||||
reload.mockClear();
|
||||
refresh.mockClear();
|
||||
if (startOptions.onComplete === undefined) throw new Error("Expected OAuth completion callback");
|
||||
startOptions.onComplete();
|
||||
|
||||
expect(reload).toHaveBeenCalledOnce();
|
||||
expect(refresh).toHaveBeenCalledOnce();
|
||||
expect(changes).toEqual([{}]);
|
||||
auth.dispose();
|
||||
expect(authFlows.disposed).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
function createAuthService(data: Parameters<typeof AuthStorage.inMemory>[0] = {}) {
|
||||
@@ -40,3 +75,17 @@ function createAuthService(data: Parameters<typeof AuthStorage.inMemory>[0] = {}
|
||||
auth.subscribe((change) => { changes.push(change); });
|
||||
return { auth, authStorage, changes };
|
||||
}
|
||||
|
||||
class CapturingOAuthLoginFlowService extends OAuthLoginFlowService {
|
||||
readonly startCalls: Parameters<OAuthLoginFlowService["start"]>[0][] = [];
|
||||
disposed = false;
|
||||
|
||||
override start(options: Parameters<OAuthLoginFlowService["start"]>[0]): OAuthFlowState {
|
||||
this.startCalls.push(options);
|
||||
return { flowId: "flow-1", providerId: options.providerId, providerName: options.providerName, status: "running", progress: [] };
|
||||
}
|
||||
|
||||
override dispose(): void {
|
||||
this.disposed = true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ afterEach(() => {
|
||||
describe("OAuthLoginFlowService", () => {
|
||||
it("round-trips prompt responses and completes the flow", async () => {
|
||||
let promptValue: string | undefined;
|
||||
const onComplete = vi.fn();
|
||||
const service = new OAuthLoginFlowService();
|
||||
const state = service.start({
|
||||
providerId: "test-provider",
|
||||
@@ -22,6 +23,7 @@ describe("OAuthLoginFlowService", () => {
|
||||
promptValue = await callbacks.onPrompt({ message: "Paste code", placeholder: "code" });
|
||||
callbacks.onProgress?.(`Got ${promptValue}`);
|
||||
}),
|
||||
onComplete,
|
||||
});
|
||||
|
||||
const prompt = state.prompt;
|
||||
@@ -35,6 +37,7 @@ describe("OAuthLoginFlowService", () => {
|
||||
|
||||
expect(promptValue).toBe("abc123");
|
||||
expect(service.get(state.flowId)).toMatchObject({ status: "complete", progress: ["Waiting for code", "Got abc123", "Login complete"] });
|
||||
expect(onComplete).toHaveBeenCalledOnce();
|
||||
service.dispose();
|
||||
});
|
||||
|
||||
@@ -113,6 +116,30 @@ describe("OAuthLoginFlowService", () => {
|
||||
service.dispose();
|
||||
});
|
||||
|
||||
it("rejects pending prompts when disposed", async () => {
|
||||
const promptRejected = deferred<Error>();
|
||||
const service = new OAuthLoginFlowService();
|
||||
const state = service.start({
|
||||
providerId: "test-provider",
|
||||
providerName: "Test Provider",
|
||||
authStorage: fakeAuthStorage(async (_providerId, callbacks) => {
|
||||
try {
|
||||
await callbacks.onPrompt({ message: "Paste code" });
|
||||
} catch (error) {
|
||||
promptRejected.resolve(toError(error));
|
||||
throw error;
|
||||
}
|
||||
}),
|
||||
});
|
||||
|
||||
expect(state.prompt).toBeDefined();
|
||||
|
||||
service.dispose();
|
||||
|
||||
await expect(promptRejected.promise).resolves.toMatchObject({ message: "Login cancelled" });
|
||||
expect(() => { service.get(state.flowId); }).toThrow("OAuth login flow not found");
|
||||
});
|
||||
|
||||
it("rejects stale or duplicate responses", () => {
|
||||
const service = new OAuthLoginFlowService();
|
||||
const state = service.start({
|
||||
|
||||
Reference in New Issue
Block a user