feat(ai): refactor device code login for copilot
This commit is contained in:
@@ -29,7 +29,62 @@ describe("GitHub Copilot OAuth device flow", () => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("waits before the first poll and increases the safety margin after slow_down", async () => {
|
||||
it("reports device-code details through onDeviceCode", async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-03-09T00:00:00Z"));
|
||||
|
||||
const fetchMock = vi.fn(async (input: unknown): Promise<Response> => {
|
||||
const url = getUrl(input);
|
||||
|
||||
if (url.endsWith("/login/device/code")) {
|
||||
return jsonResponse({
|
||||
device_code: "device-code",
|
||||
user_code: "ABCD-EFGH",
|
||||
verification_uri: "https://github.com/login/device",
|
||||
interval: 1,
|
||||
expires_in: 900,
|
||||
});
|
||||
}
|
||||
|
||||
if (url.endsWith("/login/oauth/access_token")) {
|
||||
return jsonResponse({ access_token: "ghu_refresh_token" });
|
||||
}
|
||||
|
||||
if (url.includes("/copilot_internal/v2/token")) {
|
||||
return jsonResponse({
|
||||
token: "tid=test;exp=9999999999;proxy-ep=proxy.individual.githubcopilot.com;",
|
||||
expires_at: 9999999999,
|
||||
});
|
||||
}
|
||||
|
||||
if (url.includes("/models/") && url.endsWith("/policy")) {
|
||||
return new Response("", { status: 200 });
|
||||
}
|
||||
|
||||
throw new Error(`Unexpected fetch URL: ${url}`);
|
||||
});
|
||||
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const onDeviceCode = vi.fn();
|
||||
const loginPromise = loginGitHubCopilot({
|
||||
onDeviceCode,
|
||||
onPrompt: async () => "",
|
||||
});
|
||||
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
|
||||
expect(onDeviceCode).toHaveBeenCalledWith({
|
||||
userCode: "ABCD-EFGH",
|
||||
verificationUri: "https://github.com/login/device",
|
||||
intervalSeconds: 1,
|
||||
expiresInSeconds: 900,
|
||||
});
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
await loginPromise;
|
||||
});
|
||||
|
||||
it("waits before the first poll and increases the interval after slow_down", async () => {
|
||||
vi.useFakeTimers();
|
||||
const startTime = new Date("2026-03-09T00:00:00Z");
|
||||
vi.setSystemTime(startTime);
|
||||
@@ -37,7 +92,7 @@ describe("GitHub Copilot OAuth device flow", () => {
|
||||
const accessTokenPollTimes: number[] = [];
|
||||
const accessTokenResponses = [
|
||||
jsonResponse({ error: "authorization_pending", error_description: "pending" }),
|
||||
jsonResponse({ error: "slow_down", error_description: "slow down", interval: 10 }),
|
||||
jsonResponse({ error: "slow_down", error_description: "slow down" }),
|
||||
jsonResponse({ access_token: "ghu_refresh_token" }),
|
||||
];
|
||||
|
||||
@@ -95,7 +150,7 @@ describe("GitHub Copilot OAuth device flow", () => {
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const loginPromise = loginGitHubCopilot({
|
||||
onAuth: () => {},
|
||||
onDeviceCode: () => {},
|
||||
onPrompt: async () => "",
|
||||
onProgress: () => {},
|
||||
});
|
||||
@@ -103,28 +158,28 @@ describe("GitHub Copilot OAuth device flow", () => {
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
expect(accessTokenPollTimes).toHaveLength(0);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(5999);
|
||||
await vi.advanceTimersByTimeAsync(4999);
|
||||
expect(accessTokenPollTimes).toHaveLength(0);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
expect(accessTokenPollTimes).toHaveLength(1);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(5999);
|
||||
await vi.advanceTimersByTimeAsync(4999);
|
||||
expect(accessTokenPollTimes).toHaveLength(1);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
expect(accessTokenPollTimes).toHaveLength(2);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(13999);
|
||||
await vi.advanceTimersByTimeAsync(9999);
|
||||
expect(accessTokenPollTimes).toHaveLength(2);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
await loginPromise;
|
||||
|
||||
expect(accessTokenPollTimes).toEqual([
|
||||
startTime.getTime() + 6000,
|
||||
startTime.getTime() + 12000,
|
||||
startTime.getTime() + 26000,
|
||||
startTime.getTime() + 5000,
|
||||
startTime.getTime() + 10000,
|
||||
startTime.getTime() + 20000,
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -135,8 +190,8 @@ describe("GitHub Copilot OAuth device flow", () => {
|
||||
|
||||
const accessTokenPollTimes: number[] = [];
|
||||
const accessTokenResponses = [
|
||||
jsonResponse({ error: "slow_down", error_description: "slow down", interval: 10 }),
|
||||
jsonResponse({ error: "slow_down", error_description: "still too fast", interval: 15 }),
|
||||
jsonResponse({ error: "slow_down", error_description: "slow down" }),
|
||||
jsonResponse({ error: "slow_down", error_description: "still too fast" }),
|
||||
jsonResponse({ error: "authorization_pending", error_description: "pending" }),
|
||||
];
|
||||
|
||||
@@ -168,28 +223,28 @@ describe("GitHub Copilot OAuth device flow", () => {
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const loginPromise = loginGitHubCopilot({
|
||||
onAuth: () => {},
|
||||
onDeviceCode: () => {},
|
||||
onPrompt: async () => "",
|
||||
});
|
||||
const rejection = expect(loginPromise).rejects.toThrow(
|
||||
/Device flow timed out after one or more slow_down responses/,
|
||||
);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(6000);
|
||||
expect(accessTokenPollTimes).toEqual([startTime.getTime() + 6000]);
|
||||
await vi.advanceTimersByTimeAsync(5000);
|
||||
expect(accessTokenPollTimes).toEqual([startTime.getTime() + 5000]);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(14000);
|
||||
expect(accessTokenPollTimes).toEqual([startTime.getTime() + 6000, startTime.getTime() + 20000]);
|
||||
await vi.advanceTimersByTimeAsync(10000);
|
||||
expect(accessTokenPollTimes).toEqual([startTime.getTime() + 5000, startTime.getTime() + 15000]);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(4999);
|
||||
expect(accessTokenPollTimes).toEqual([startTime.getTime() + 6000, startTime.getTime() + 20000]);
|
||||
await vi.advanceTimersByTimeAsync(9999);
|
||||
expect(accessTokenPollTimes).toEqual([startTime.getTime() + 5000, startTime.getTime() + 15000]);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
await rejection;
|
||||
|
||||
expect(accessTokenPollTimes).toEqual([
|
||||
startTime.getTime() + 6000,
|
||||
startTime.getTime() + 20000,
|
||||
startTime.getTime() + 5000,
|
||||
startTime.getTime() + 15000,
|
||||
startTime.getTime() + 25000,
|
||||
]);
|
||||
});
|
||||
|
||||
55
packages/ai/test/oauth-device-code.test.ts
Normal file
55
packages/ai/test/oauth-device-code.test.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { pollOAuthDeviceCodeFlow } from "../src/utils/oauth/device-code.ts";
|
||||
|
||||
describe("OAuth device-code polling", () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("waits before the first poll and returns the completed value", async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-03-09T00:00:00Z"));
|
||||
|
||||
const pollTimes: number[] = [];
|
||||
const poll = vi.fn(async () => {
|
||||
pollTimes.push(Date.now());
|
||||
return pollTimes.length === 1
|
||||
? { status: "pending" as const }
|
||||
: { status: "complete" as const, accessToken: "token" };
|
||||
});
|
||||
|
||||
const resultPromise = pollOAuthDeviceCodeFlow({
|
||||
intervalSeconds: 2,
|
||||
expiresInSeconds: 30,
|
||||
poll,
|
||||
});
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1999);
|
||||
expect(pollTimes).toEqual([]);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
expect(pollTimes).toEqual([new Date("2026-03-09T00:00:02Z").getTime()]);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(2000);
|
||||
await expect(resultPromise).resolves.toBe("token");
|
||||
expect(pollTimes).toEqual([
|
||||
new Date("2026-03-09T00:00:02Z").getTime(),
|
||||
new Date("2026-03-09T00:00:04Z").getTime(),
|
||||
]);
|
||||
});
|
||||
|
||||
it("cancels an in-flight wait", async () => {
|
||||
vi.useFakeTimers();
|
||||
const controller = new AbortController();
|
||||
|
||||
const resultPromise = pollOAuthDeviceCodeFlow({
|
||||
intervalSeconds: 5,
|
||||
expiresInSeconds: 30,
|
||||
poll: async () => ({ status: "pending" }),
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
controller.abort();
|
||||
await expect(resultPromise).rejects.toThrow("Login cancelled");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user