Release v1.0.2: broaden offline test coverage

Adds one test file per source file plus a shared fetch mock helper:

- test/constants.test.ts: OAuth scope/client_id/PROVIDER_ID/MODEL_ID pins,
  rules 1, 6, 8.
- test/headers.test.ts: seven fingerprint header keys, UA/X-Msh-Version
  track KIMI_CLI_VERSION, ASCII-only values, device-id format and
  stability across calls (rules 1, 2).
- test/oauth.test.ts: form-encoded bodies for device-start/refresh,
  authorization_pending/expired_token/unknown-error branches, non-OK and
  non-JSON error shapes.
- test/plugin.test.ts: chat.params provider+model gating, full effort
  matrix (rule 4), prompt_cache_key gating (rule 5), camelCase effort
  input, loader Authorization ownership (rule 3), refresh-on-expiry with
  persistAuth, 401 single-retry no-loop, device-flow authorize wiring.
- test/_util/fetchMock.ts: zero-dep global-fetch swap returning canned
  Responses with call capture.

Notes:

- Tests use the real ~/.kimi/device_id; getDeviceId is idempotent and the
  file is shared with kimi-cli by design (AGENTS.md rule 2), so no HOME
  redirect is needed. Attempted monkey-patching os.homedir instead is
  fragile under Bun's eager import-binding resolution.
- pollDeviceToken tests use interval=1 because `device.interval || 5`
  treats 0 as the default 5s and would push each iteration past the
  default bun-test timeout.

Total: 35 tests, ~5s to run.
This commit is contained in:
lemon07r
2026-04-17 03:18:23 -04:00
parent 0626597690
commit 64550fb057
7 changed files with 519 additions and 1 deletions
+1
View File
@@ -78,6 +78,7 @@ These are the invariants that, if broken, silently degrade K2.6 → K2.5 or prod
- **Git commits:** small, logical, imperative subject ("Add oauth device flow"). Do not add a `Co-authored-by` trailer.
- **Upstream research:** the `research/` directory is a read-only git-ignored pair of shallow clones (opencode + kimi-cli) for grep. Never edit files there; re-clone if you suspect drift. When citing upstream in a comment, use the `research/…` path so the reference is resolvable.
- **Version bumps:** when kimi-cli bumps, (1) pull a fresh `research/kimi-cli`, (2) update `KIMI_CLI_VERSION` in `src/constants.ts`, (3) re-diff `_kimi_default_headers()` / `oauth.py` against `src/headers.ts` and `src/oauth.ts`, (4) smoke-test with `opencode auth login kimi-for-coding-oauth` and a one-turn chat, (5) tag release.
- **Tests:** `test/` holds one file per source file plus `test/exports.test.ts` (the rule-9 guard). Tests mock `fetch` via `test/_util/fetchMock.ts`; no real credentials or network. They use the real `~/.kimi/device_id` on purpose — it is shared with kimi-cli by design and `getDeviceId` is idempotent, so tests don't clobber state. When adding a new contract to the list above, add the matching offline check to the corresponding test file rather than creating new ones.
### What not to do
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "opencode-kimi-full",
"version": "1.0.1",
"version": "1.0.2",
"description": "OpenCode plugin that adds first-class support for Kimi K2.6 (kimi-for-coding) via the official Kimi OAuth device flow, matching the upstream kimi-cli 1:1.",
"license": "MIT",
"repository": {
+51
View File
@@ -0,0 +1,51 @@
// Minimal typed fetch mock. Bun's `bun:test` has `mock()` but swapping
// `globalThis.fetch` with a plain function is enough here and keeps tests
// free of framework-specific mocking magic.
export type FetchCall = {
url: string
method: string
headers: Record<string, string>
body: string | undefined
}
export type Responder = (
call: FetchCall,
callIndex: number,
) => { status?: number; body?: unknown; bodyText?: string }
export function installFetchMock(responder: Responder) {
const calls: FetchCall[] = []
const original = globalThis.fetch
globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : (input as Request).url
const headers: Record<string, string> = {}
const hs = new Headers(init?.headers)
hs.forEach((v, k) => {
headers[k] = v
})
const body = typeof init?.body === "string" ? init.body : init?.body == null ? undefined : String(init.body)
const call: FetchCall = { url, method: (init?.method ?? "GET").toUpperCase(), headers, body }
calls.push(call)
const r = responder(call, calls.length - 1)
const status = r.status ?? 200
const text = r.bodyText ?? (r.body === undefined ? "" : JSON.stringify(r.body))
return new Response(text, {
status,
headers: { "Content-Type": "application/json" },
})
}) as typeof fetch
return {
calls,
restore: () => {
globalThis.fetch = original
},
}
}
export function parseForm(body: string | undefined): Record<string, string> {
if (!body) return {}
const out: Record<string, string> = {}
for (const [k, v] of new URLSearchParams(body)) out[k] = v
return out
}
+42
View File
@@ -0,0 +1,42 @@
import { test, expect } from "bun:test"
import * as C from "../src/constants.ts"
// These values form the "identity" of the plugin on the wire. Typos silently
// downgrade K2.6 → K2.5 (scope/client_id) or collide with models.dev
// (PROVIDER_ID). See AGENTS.md "Contracts to keep intact".
test("KIMI_CLI_VERSION is a non-empty semver", () => {
expect(C.KIMI_CLI_VERSION).toMatch(/^\d+\.\d+\.\d+$/)
})
test("USER_AGENT embeds KIMI_CLI_VERSION", () => {
expect(C.USER_AGENT).toBe(`KimiCodeCLI/${C.KIMI_CLI_VERSION}`)
})
test("OAuth constants match upstream kimi-cli exactly", () => {
// Pinned values from research/kimi-cli/src/kimi_cli/auth/oauth.py. If these
// drift from upstream, tokens are issued against the wrong scope/client and
// the backend routes to K2.5.
expect(C.OAUTH_HOST).toBe("https://auth.kimi.com")
expect(C.OAUTH_DEVICE_AUTH_URL).toBe("https://auth.kimi.com/api/oauth/device_authorization")
expect(C.OAUTH_TOKEN_URL).toBe("https://auth.kimi.com/api/oauth/token")
expect(C.OAUTH_CLIENT_ID).toBe("17e5f671-d194-4dfb-9706-5516cb48c098")
expect(C.OAUTH_SCOPE).toBe("kimi-code")
expect(C.OAUTH_DEVICE_GRANT).toBe("urn:ietf:params:oauth:grant-type:device_code")
expect(C.OAUTH_REFRESH_GRANT).toBe("refresh_token")
})
test("PROVIDER_ID does not collide with models.dev (AGENTS.md rule 8)", () => {
expect(C.PROVIDER_ID).toBe("kimi-for-coding-oauth")
expect(C.PROVIDER_ID).not.toBe("kimi-for-coding")
})
test("MODEL_ID goes over the wire verbatim (AGENTS.md rule 6)", () => {
expect(C.MODEL_ID).toBe("kimi-for-coding")
})
test("REFRESH_SAFETY_WINDOW_MS is positive and well below token TTL", () => {
// Token TTLs are ~15 min; anything bigger would mean we refresh on every call.
expect(C.REFRESH_SAFETY_WINDOW_MS).toBeGreaterThan(0)
expect(C.REFRESH_SAFETY_WINDOW_MS).toBeLessThan(5 * 60_000)
})
+60
View File
@@ -0,0 +1,60 @@
import { test, expect } from "bun:test"
import fs from "node:fs"
import os from "node:os"
import path from "node:path"
import { KIMI_CLI_VERSION, USER_AGENT } from "../src/constants.ts"
import { getDeviceId, kimiHeaders } from "../src/headers.ts"
// Note: getDeviceId() reads/writes ~/.kimi/device_id. That file is shared
// with kimi-cli on purpose (AGENTS.md rule 2) and the function is
// idempotent — if the file exists we reuse it, otherwise we create it. The
// tests therefore use the real HOME: they cannot clobber anything, and
// mocking `os.homedir` for Node's built-in `os` is fragile (Bun resolves
// the import binding eagerly).
test("kimiHeaders emits exactly the 7 fingerprint keys kimi-cli sends", () => {
const h = kimiHeaders()
expect(Object.keys(h).sort()).toEqual(
[
"User-Agent",
"X-Msh-Device-Id",
"X-Msh-Device-Model",
"X-Msh-Device-Name",
"X-Msh-Os-Version",
"X-Msh-Platform",
"X-Msh-Version",
].sort(),
)
})
test("User-Agent and X-Msh-Version track KIMI_CLI_VERSION (AGENTS.md rule 1)", () => {
const h = kimiHeaders()
expect(h["User-Agent"]).toBe(USER_AGENT)
expect(h["User-Agent"]).toContain(KIMI_CLI_VERSION)
expect(h["X-Msh-Version"]).toBe(KIMI_CLI_VERSION)
expect(h["X-Msh-Platform"]).toBe("kimi_cli")
})
test("All header values are ASCII-only (undici rejects non-ASCII)", () => {
for (const [k, v] of Object.entries(kimiHeaders())) {
expect(v, `header ${k}`).toMatch(/^[\x20-\x7e]+$/)
}
})
test("Device id is a 32-char lowercase hex string (kimi-cli UUIDv4 no-dashes format)", () => {
expect(getDeviceId()).toMatch(/^[0-9a-f]{32}$/)
})
test("Device id is stable across calls and matches ~/.kimi/device_id on disk (AGENTS.md rule 2)", () => {
const first = getDeviceId()
const second = getDeviceId()
expect(second).toBe(first)
// Mirrors kimi-cli's path; shared by design.
const onDisk = fs.readFileSync(path.join(os.homedir(), ".kimi", "device_id"), "utf8").trim()
expect(onDisk).toBe(first)
})
test("Device id is also present and matches in the headers map", () => {
const h = kimiHeaders()
expect(h["X-Msh-Device-Id"]).toBe(getDeviceId())
})
+118
View File
@@ -0,0 +1,118 @@
import { test, expect, afterEach } from "bun:test"
import {
OAUTH_CLIENT_ID,
OAUTH_DEVICE_AUTH_URL,
OAUTH_DEVICE_GRANT,
OAUTH_REFRESH_GRANT,
OAUTH_SCOPE,
OAUTH_TOKEN_URL,
} from "../src/constants.ts"
import { pollDeviceToken, refreshToken, startDeviceAuth } from "../src/oauth.ts"
import { installFetchMock, parseForm } from "./_util/fetchMock.ts"
// oauth.ts calls kimiHeaders() on every request, which reads/writes
// ~/.kimi/device_id. That file is shared with kimi-cli by design and
// getDeviceId is idempotent — no HOME redirect needed.
let mock: ReturnType<typeof installFetchMock> | undefined
afterEach(() => {
mock?.restore()
mock = undefined
})
test("startDeviceAuth posts client_id+scope as form-encoded to the device endpoint", async () => {
mock = installFetchMock(() => ({
body: {
device_code: "dc",
user_code: "USER-1234",
verification_uri: "https://auth.kimi.com/device",
verification_uri_complete: "https://auth.kimi.com/device?u=USER-1234",
expires_in: 600,
interval: 5,
},
}))
const d = await startDeviceAuth()
expect(d.user_code).toBe("USER-1234")
expect(mock.calls).toHaveLength(1)
const call = mock.calls[0]!
expect(call.url).toBe(OAUTH_DEVICE_AUTH_URL)
expect(call.method).toBe("POST")
expect(call.headers["content-type"]).toBe("application/x-www-form-urlencoded")
// Fingerprint headers must be present on every oauth call, not just chat.
expect(call.headers["x-msh-version"]).toBeDefined()
expect(call.headers["x-msh-device-id"]).toMatch(/^[0-9a-f]{32}$/)
expect(parseForm(call.body)).toEqual({
client_id: OAUTH_CLIENT_ID,
scope: OAUTH_SCOPE,
})
})
test("refreshToken posts grant_type=refresh_token and returns normalized shape", async () => {
mock = installFetchMock(() => ({
body: { access_token: "a2", refresh_token: "r2", token_type: "Bearer", expires_in: 900 },
}))
const t = await refreshToken("r1")
expect(t).toEqual({ access_token: "a2", refresh_token: "r2", token_type: "Bearer", expires_in: 900 })
const call = mock.calls[0]!
expect(call.url).toBe(OAUTH_TOKEN_URL)
expect(parseForm(call.body)).toEqual({
client_id: OAUTH_CLIENT_ID,
refresh_token: "r1",
grant_type: OAUTH_REFRESH_GRANT,
})
})
test("postForm wraps non-OK responses with error.code from the JSON body", async () => {
mock = installFetchMock(() => ({
status: 400,
body: { error: "invalid_grant", error_description: "refresh token is dead" },
}))
await expect(refreshToken("bad")).rejects.toThrow(/invalid_grant/)
})
test("postForm throws a clear error when the server returns non-JSON", async () => {
mock = installFetchMock(() => ({ status: 502, bodyText: "<html>gateway</html>" }))
await expect(refreshToken("x")).rejects.toThrow(/non-JSON response/)
})
test("pollDeviceToken honors authorization_pending and returns on approval", async () => {
// pollDeviceToken clamps with `device.interval || 5` then max(1, …)*1000,
// so the effective poll wait is max(1, interval) seconds. Use interval=1
// and a single pending cycle to keep the test ~2s.
const device = {
device_code: "dc",
user_code: "U",
verification_uri: "x",
expires_in: 60,
interval: 1,
}
mock = installFetchMock((_, i) => {
if (i < 1) return { status: 400, body: { error: "authorization_pending" } }
return { body: { access_token: "A", refresh_token: "R", token_type: "Bearer", expires_in: 900 } }
})
const t = await pollDeviceToken(device)
expect(t.access_token).toBe("A")
expect(mock.calls).toHaveLength(2)
// Sends device_code + the RFC 8628 grant type.
expect(parseForm(mock.calls[0]!.body)).toEqual({
client_id: OAUTH_CLIENT_ID,
device_code: "dc",
grant_type: OAUTH_DEVICE_GRANT,
})
})
test("pollDeviceToken surfaces expired_token with an actionable message", async () => {
mock = installFetchMock(() => ({ status: 400, body: { error: "expired_token" } }))
await expect(
pollDeviceToken({ device_code: "dc", user_code: "U", verification_uri: "x", expires_in: 60, interval: 1 }),
).rejects.toThrow(/device code expired/)
})
test("pollDeviceToken rethrows unknown errors without looping", async () => {
mock = installFetchMock(() => ({ status: 400, body: { error: "access_denied", error_description: "nope" } }))
await expect(
pollDeviceToken({ device_code: "dc", user_code: "U", verification_uri: "x", expires_in: 60, interval: 1 }),
).rejects.toThrow(/access_denied/)
// Exactly one call; not retried.
expect(mock!.calls).toHaveLength(1)
})
+246
View File
@@ -0,0 +1,246 @@
import { test, expect, afterEach } from "bun:test"
import plugin from "../src/index.ts"
import { MODEL_ID, PROVIDER_ID, REFRESH_SAFETY_WINDOW_MS } from "../src/constants.ts"
import { installFetchMock } from "./_util/fetchMock.ts"
// kimiHeaders() → getDeviceId() reads/writes ~/.kimi/device_id; that file is
// shared with kimi-cli by design and writes are idempotent — no HOME
// redirect needed.
let mock: ReturnType<typeof installFetchMock> | undefined
afterEach(() => {
mock?.restore()
mock = undefined
})
// Fake opencode plugin context. Only `client.auth.set` is used by the
// plugin's writes; reads go through the `readAuth` callback passed to
// `loader`, not through client.
function makeContext() {
const writes: Array<{ id: string; body: unknown }> = []
return {
writes,
ctx: {
client: {
auth: {
set: async ({ path, body }: { path: { id: string }; body: unknown }) => {
writes.push({ id: path.id, body })
},
},
},
} as unknown as Parameters<typeof plugin>[0],
}
}
async function getHooks() {
const { ctx, writes } = makeContext()
const hooks = await plugin(ctx)
return { hooks, writes }
}
// ---------- chat.params -----------------------------------------------------
// Minimal shape for input/output we care about in the params hook.
type ParamsInput = { provider: { info: { id: string } }; model: { id: string }; sessionID: string }
type ParamsOutput = { options: Record<string, unknown> }
function callParams(
hook: (i: ParamsInput, o: ParamsOutput) => Promise<void> | void,
providerID: string,
modelID: string,
options: Record<string, unknown> = {},
sessionID = "sess-1",
) {
const output: ParamsOutput = { options: { ...options } }
const res = hook({ provider: { info: { id: providerID } }, model: { id: modelID }, sessionID }, output)
return { res, output }
}
test("chat.params: no-op for other providers (AGENTS.md rule: gated on PROVIDER_ID)", async () => {
const { hooks } = await getHooks()
const hook = hooks["chat.params"]!
const { output } = callParams(hook, "some-other-provider", MODEL_ID, { reasoning_effort: "high" })
// Untouched — no prompt_cache_key, no thinking added.
expect(output.options).toEqual({ reasoning_effort: "high" })
})
test("chat.params: no-op for other models under our provider (rule 5 gating)", async () => {
const { hooks } = await getHooks()
const hook = hooks["chat.params"]!
const { output } = callParams(hook, PROVIDER_ID, "kimi-something-else")
expect(output.options.prompt_cache_key).toBeUndefined()
expect(output.options.thinking).toBeUndefined()
})
test("chat.params: attaches prompt_cache_key = sessionID for kimi-for-coding only", async () => {
const { hooks } = await getHooks()
const hook = hooks["chat.params"]!
const { output } = await callParams(hook, PROVIDER_ID, MODEL_ID, {}, "sess-42")
expect(output.options.prompt_cache_key).toBe("sess-42")
})
// The effort matrix is the most load-bearing contract in AGENTS.md → rule 4.
// Off → thinking disabled, reasoning_effort stripped.
// low/medium/high → reasoning_effort kept, thinking enabled.
// unset → thinking enabled, no reasoning_effort (server-picks default).
const EFFORT_MATRIX: Array<{
in: Record<string, unknown>
effort: string | undefined
thinkingType: "enabled" | "disabled"
}> = [
{ in: { reasoning_effort: "off" }, effort: undefined, thinkingType: "disabled" },
{ in: { reasoning_effort: "low" }, effort: "low", thinkingType: "enabled" },
{ in: { reasoning_effort: "medium" }, effort: "medium", thinkingType: "enabled" },
{ in: { reasoning_effort: "high" }, effort: "high", thinkingType: "enabled" },
{ in: {}, effort: undefined, thinkingType: "enabled" },
]
for (const row of EFFORT_MATRIX) {
test(`chat.params: effort=${JSON.stringify(row.in)} → effort=${row.effort}, thinking=${row.thinkingType}`, async () => {
const { hooks } = await getHooks()
const { output } = await callParams(hooks["chat.params"]!, PROVIDER_ID, MODEL_ID, row.in)
expect(output.options.reasoning_effort).toBe(row.effort)
expect(output.options.thinking).toEqual({ type: row.thinkingType })
})
}
test("chat.params: `reasoningEffort` (camelCase) input also drives the mapping", async () => {
// opencode may use camelCase upstream; the plugin accepts either.
const { hooks } = await getHooks()
const { output } = await callParams(hooks["chat.params"]!, PROVIDER_ID, MODEL_ID, { reasoningEffort: "off" })
expect(output.options.thinking).toEqual({ type: "disabled" })
expect(output.options.reasoning_effort).toBeUndefined()
expect(output.options.reasoningEffort).toBeUndefined()
})
// ---------- auth.loader -----------------------------------------------------
function jwt() {
return "header.payload.sig"
}
function validAuth(overrides: Partial<{ access: string; refresh: string; expires: number }> = {}) {
return {
type: "oauth" as const,
access: overrides.access ?? "access-1",
refresh: overrides.refresh ?? "refresh-1",
// Far enough in the future to skip the refresh-on-expiry path.
expires: overrides.expires ?? Date.now() + 10 * 60_000,
}
}
async function getLoaderFetch(readAuth: () => Promise<unknown>) {
const { hooks, writes } = await getHooks()
const res = await hooks.auth!.loader!(readAuth as any, {} as any)
return { fetch: (res as { fetch: typeof fetch }).fetch, apiKey: (res as { apiKey: string }).apiKey, writes }
}
test("auth.loader: refuses to run when no credentials are persisted", async () => {
const { fetch: f } = await getLoaderFetch(async () => undefined)
await expect(f("https://api.kimi.com/coding/v1/models")).rejects.toThrow(/not logged in/)
})
test("auth.loader: apiKey sentinel is returned (opencode requires truthy)", async () => {
const { apiKey } = await getLoaderFetch(async () => validAuth())
expect(apiKey).toBe("kimi-for-coding-oauth")
})
test("auth.loader: owns Authorization and strips any caller-supplied value (rule 3)", async () => {
mock = installFetchMock(() => ({ body: { ok: true } }))
const { fetch: f } = await getLoaderFetch(async () => validAuth({ access: jwt() }))
await f("https://api.kimi.com/coding/v1/chat", {
method: "POST",
headers: { Authorization: "Bearer SHOULD-BE-OVERRIDDEN", authorization: "lower-also" },
body: JSON.stringify({}),
})
expect(mock.calls).toHaveLength(1)
const h = mock.calls[0]!.headers
expect(h["authorization"]).toBe(`Bearer ${jwt()}`)
// Seven kimi-cli fingerprint headers are attached on every request.
expect(h["x-msh-platform"]).toBe("kimi_cli")
expect(h["x-msh-version"]).toBeDefined()
expect(h["x-msh-device-id"]).toMatch(/^[0-9a-f]{32}$/)
})
test("auth.loader: refreshes when expiry is within safety window", async () => {
let reads = 0
const initial = validAuth({ expires: Date.now() + REFRESH_SAFETY_WINDOW_MS / 2 })
let current: ReturnType<typeof validAuth> = initial
const readAuth = async () => {
reads++
return current
}
// First fetch call → token refresh, second → the actual request.
mock = installFetchMock((call) => {
if (call.url.includes("/oauth/token")) {
return { body: { access_token: "access-2", refresh_token: "refresh-2", token_type: "Bearer", expires_in: 900 } }
}
return { body: { ok: true } }
})
const { fetch: f, writes } = await getLoaderFetch(readAuth)
await f("https://api.kimi.com/coding/v1/chat")
// One refresh call + one API call.
expect(mock.calls.map((c) => c.url)).toEqual([
"https://auth.kimi.com/api/oauth/token",
"https://api.kimi.com/coding/v1/chat",
])
expect(mock.calls[1]!.headers["authorization"]).toBe("Bearer access-2")
// Persisted the refreshed token back to opencode's auth store.
expect(writes).toHaveLength(1)
expect(writes[0]!.id).toBe(PROVIDER_ID)
expect((writes[0]!.body as { access: string }).access).toBe("access-2")
expect(reads).toBeGreaterThan(0)
})
test("auth.loader: 401 triggers exactly one forced refresh + retry (no infinite loop)", async () => {
let current = validAuth({ access: "stale" })
const readAuth = async () => current
mock = installFetchMock((call) => {
if (call.url.includes("/oauth/token")) {
current = { ...current, access: "fresh" }
return { body: { access_token: "fresh", refresh_token: "refresh-2", token_type: "Bearer", expires_in: 900 } }
}
// First API call: stale → 401. Every subsequent API call: 401 as well.
// The loader must NOT loop; exactly one retry after refresh.
return { status: 401, body: { error: "unauthorized" } }
})
const { fetch: f } = await getLoaderFetch(readAuth)
const res = await f("https://api.kimi.com/coding/v1/chat")
expect(res.status).toBe(401)
const urls = mock.calls.map((c) => c.url)
// Expected order: stale call → refresh → retry with fresh token → STOP.
expect(urls).toEqual([
"https://api.kimi.com/coding/v1/chat",
"https://auth.kimi.com/api/oauth/token",
"https://api.kimi.com/coding/v1/chat",
])
expect(mock.calls[0]!.headers["authorization"]).toBe("Bearer stale")
expect(mock.calls[2]!.headers["authorization"]).toBe("Bearer fresh")
})
// ---------- auth.methods (device flow wiring) -------------------------------
test("auth.methods[0].authorize returns URL + instructions + async callback", async () => {
mock = installFetchMock((call) => {
if (call.url.includes("device_authorization")) {
return {
body: {
device_code: "dc",
user_code: "WXYZ-1234",
verification_uri: "https://auth.kimi.com/device",
verification_uri_complete: "https://auth.kimi.com/device?u=WXYZ-1234",
expires_in: 60,
interval: 1,
},
}
}
return { body: { access_token: "A", refresh_token: "R", token_type: "Bearer", expires_in: 900 } }
})
const { hooks } = await getHooks()
const method = hooks.auth!.methods![0] as { authorize: () => Promise<any> }
const r = await method.authorize()
expect(r.url).toBe("https://auth.kimi.com/device?u=WXYZ-1234")
expect(r.instructions).toContain("WXYZ-1234")
const cb = await r.callback()
expect(cb.type).toBe("success")
expect(cb.access).toBe("A")
expect(cb.refresh).toBe("R")
expect(typeof cb.expires).toBe("number")
})