Merge native Copilot and Grok subscription connections
This commit is contained in:
@@ -17,7 +17,7 @@ const dom = parseHTML("<html><body></body></html>");
|
||||
Object.assign(globalThis, { window: dom.window, document: dom.document, Element: dom.window.Element, HTMLElement: dom.window.HTMLElement, CustomEvent: dom.window.CustomEvent, IS_REACT_ACT_ENVIRONMENT: true, localStorage: { getItem: () => null, setItem() {} } });
|
||||
const originalFetch = globalThis.fetch;
|
||||
let root: Root | undefined, host: HTMLDivElement | undefined;
|
||||
const account = (id = "private-registration-id", email = "learner@example.invalid", connected = true) => ({ id, email, registration: "private-issued-client", connected, planEnabled: connected });
|
||||
const account = (id = "private-registration-id", email = "learner@example.invalid", connected = true) => ({ id, email, registration: "private-issued-client", connected, planEnabled: connected, canSignInAgain: !connected });
|
||||
const disconnected = (allowBackgroundUse = true): ChatGPTStatus => ({ available: true, pending: false, allowBackgroundUse, activeId: null, accounts: [account("signed-out-record", "signed-out@example.invalid", false)], error: null });
|
||||
const connected = (allowBackgroundUse = true): ChatGPTStatus => ({ ...disconnected(allowBackgroundUse), activeId: "private-registration-id", accounts: [account(), ...disconnected().accounts] });
|
||||
const storageError = "ChatGPT credentials could not be read or saved. Check local file access, then reconnect before making requests.";
|
||||
@@ -42,6 +42,16 @@ function fixture(initial = disconnected(), overrides: Partial<ChatGPTControlBrid
|
||||
window.lh = { expandWindow() {}, chatgpt: bridge };
|
||||
return { calls, bridge, push, get status() { return status; }, complete() { push({ ...connected(status.allowBackgroundUse), pending: false }); } };
|
||||
}
|
||||
function recoveryController({ writeFails = false, tokens = "valid" }: { writeFails?: boolean; tokens?: "valid" | "missing" | "restricted" | "scope" | "expired" } = {}) {
|
||||
const require = createRequire(import.meta.url); const { createChatGPT, SCOPES } = require("../electron/chatgpt.cjs"); const { chatGPTControl } = require("../electron/chatgpt-ipc.cjs");
|
||||
const registration: any = { id: "fixture", clientId: "oaiapp_fixture", issuer: "https://auth.openai.com", subject: "fixture", email: "fixture@example.invalid", tokens: { accessToken: "fake-access", refreshToken: "fake-refresh", idToken: "fake-id", expiresAt: tokens === "expired" ? 0 : Date.now() + 3600000, scopes: tokens === "scope" ? ["openid"] : [...SCOPES] } };
|
||||
if (tokens === "missing") delete registration.tokens; if (tokens === "restricted") registration.reconnectRequired = true;
|
||||
let saved = { version: 1, hostId: "urn:uuid:offline-recovery", activeId: "fixture", allowBackgroundUse: true, registrations: [registration] };
|
||||
let browserAttempts = 0, tokenRequests = 0; const listeners = new Set<() => void>();
|
||||
const service = createChatGPT({ store: { read: async () => structuredClone(saved), write: async (next: typeof saved) => { if (writeFails) throw Object.assign(new Error("fake-storage-failure"), { code: "storage" }); saved = structuredClone(next); } }, openExternal: () => { browserAttempts++; throw new Error("No real browser"); }, fetch: async (url: string) => { tokenRequests++; assert.equal(tokens, "expired"); assert.equal(url, "https://auth.openai.com/api/accounts/oauth/token"); return Response.json({ error: "invalid_grant" }, { status: 401 }); }, onChange: () => { for (const cb of listeners) cb(); } });
|
||||
window.lh = { expandWindow() {}, chatgpt: { status: () => chatGPTControl({ op: "status" }, service), consent: (enabled) => chatGPTControl({ op: "consent", enabled }, service), login: (id) => chatGPTControl({ op: "login", id }, service), cancel: () => chatGPTControl({ op: "cancel" }, service), select: (id) => chatGPTControl({ op: "select", id }, service), disconnect: (id) => chatGPTControl({ op: "disconnect", id }, service), logout: (id) => chatGPTControl({ op: "logout", id }, service), onChange: (cb) => { listeners.add(cb); return () => listeners.delete(cb); } } };
|
||||
return { service, get saved() { return saved; }, get browserAttempts() { return browserAttempts; }, get tokenRequests() { return tokenRequests; } };
|
||||
}
|
||||
async function mount(children: React.ReactNode = <ChatGPTConnection />) {
|
||||
host = document.createElement("div"); document.body.append(host); root = createRoot(host);
|
||||
await act(async () => root!.render(<ChatGPTUIProvider>{children}</ChatGPTUIProvider>));
|
||||
@@ -82,12 +92,15 @@ test("Providers has exactly four ordered sections; unknown variables stay masked
|
||||
assert.equal(byTest("unknown-FUTURE_VAR").textContent, "FUTURE_VAR…abcd"); assert(!byTest("unknown-FUTURE_VAR").querySelector("input, button"));
|
||||
});
|
||||
|
||||
test("disconnected row is only secondary Continue; initial default is ON; cancel discards draft without OAuth/global mutation", async () => {
|
||||
test("disconnected row uses shared Connect action; initial default is ON; cancel discards draft without OAuth/global mutation", async () => {
|
||||
const f = fixture(); await mount(<ChatGPTConnection setup />);
|
||||
assert.equal(byTest("chatgpt-connection").textContent, "Continue with ChatGPT");
|
||||
assert.equal(byTest("chatgpt-connection").textContent, "ChatGPTConnect");
|
||||
assert(!document.body.textContent?.includes(CHATGPT_NOTICE)); const trigger = byTest("chatgpt-login");
|
||||
assert.match(trigger.className, /cursor-pointer/); assert.match(trigger.className, /hover:bg-accent/); assert.match(trigger.className, /focus-visible:ring/);
|
||||
await click(trigger); assert.equal(f.calls.login.length, 0); assert(document.body.textContent?.includes(CHATGPT_NOTICE));
|
||||
assert.equal(byTest("chatgpt-account-dialog").querySelector("h2")?.textContent, "ChatGPT");
|
||||
assert.equal(byTest("chatgpt-proceed").textContent, "Continue with ChatGPT"); assert(byTest("chatgpt-proceed").querySelector('img[src="/siwc/chatgpt-logo-white.svg"]'));
|
||||
assert(!button("Continue", byTest("chatgpt-account-dialog")), "ChatGPT remains one step, with disclosure and toggle together");
|
||||
assert.equal(toggle().getAttribute("aria-checked"), "true");
|
||||
assert.equal(toggle().getAttribute("aria-label"), "Allow background use for all ChatGPT accounts");
|
||||
assert(byTest("chatgpt-global-background").textContent?.includes("Background work counts toward your ChatGPT plan usage."));
|
||||
@@ -180,23 +193,33 @@ test("email-only connected selector and Manage remain responsive; account select
|
||||
assert.deepEqual([...document.querySelectorAll('select[aria-label="ChatGPT account"] option')].map((o) => o.textContent), ["learner@example.invalid", "second@example.invalid", "ChatGPT account"]);
|
||||
assert.doesNotMatch(byTest("chatgpt-connection").textContent!, /private-registration|private-issued| · active|signed-out/);
|
||||
assert.match(byTest("chatgpt-connection").firstElementChild!.className, /items-center/);
|
||||
const row = byTest("chatgpt-connection").firstElementChild!;
|
||||
assert.equal(row.children[0]!.textContent, "ChatGPT"); assert.equal(row.children[1]!.tagName, "SELECT"); assert.equal(row.children[2]!.textContent, "Manage…");
|
||||
assert.match(row.children[2]!.className, /text-muted-foreground/); assert(!row.textContent?.includes("Connected"));
|
||||
await selectAccount("ChatGPT account", "second"); assert.deepEqual(f.calls.select, ["second"]); assert.equal(f.status.allowBackgroundUse, false);
|
||||
await click(button("Manage…")); assert(byTest("chatgpt-managed-identity").textContent?.includes("second@example.invalid"));
|
||||
assert.deepEqual([...byTest("chatgpt-account-footer").querySelectorAll("button")].map((b) => b.textContent), ["Add accounts", "Close"]);
|
||||
assert.deepEqual([...byTest("chatgpt-account-footer").querySelectorAll("button")].map((b) => b.textContent), ["Disconnect", "Close"]);
|
||||
const dialog = byTest("chatgpt-account-dialog").innerHTML;
|
||||
assert(dialog.indexOf('data-testid="chatgpt-managed-identity"') < dialog.indexOf('data-testid="chatgpt-account-actions"'));
|
||||
assert(dialog.indexOf('data-testid="chatgpt-account-actions"') < dialog.indexOf('data-testid="chatgpt-global-background"'));
|
||||
assert(dialog.indexOf('data-testid="chatgpt-global-background"') < dialog.indexOf('data-testid="chatgpt-managed-identity"'));
|
||||
assert(!byTest("chatgpt-account-dialog").querySelector('[data-testid="chatgpt-privacy-notice"]'));
|
||||
assert.equal(byTest("chatgpt-account-dialog").querySelector("h2")?.textContent, "ChatGPT");
|
||||
assert(byTest("chatgpt-account-dialog").querySelector('a[href="https://openai.com/policies/privacy-policy/"]'));
|
||||
assert(byTest("chatgpt-account-dialog").querySelector('a[href="https://chatgpt.com/settings/usage"]'));
|
||||
await click(toggle()); assert.deepEqual(f.calls.consent, [true]); assert.equal(toggle().getAttribute("aria-checked"), "true");
|
||||
await click(button("Close")); await selectAccount("ChatGPT account", "private-registration-id"); assert.equal(f.status.allowBackgroundUse, true);
|
||||
assert.equal(f.calls.logout, 0);
|
||||
});
|
||||
|
||||
test("Disconnect account stays under identity, outside the nondestructive Manage footer; confirmation Cancel never forgets", async () => {
|
||||
test("Disconnect is the quiet red left-footer action below identity; confirmation Cancel never forgets", async () => {
|
||||
assert.deepEqual(CHATGPT_DISCONNECT_COPY, { title: "Disconnect account?", body: "This removes the saved account and its credentials from this device." });
|
||||
const f = fixture(connected()); await mount(); await click(button("Manage…"));
|
||||
assert.equal(byTest("chatgpt-account-actions").textContent, "Disconnect account"); assert.match(byTest("chatgpt-disconnect").className, /text-red/);
|
||||
assert(!byTest("chatgpt-account-footer").contains(byTest("chatgpt-disconnect")));
|
||||
assert.equal(byTest("chatgpt-account-actions").textContent, "Disconnect"); assert.match(byTest("chatgpt-disconnect").className, /text-red/);
|
||||
assert.equal(byTest("chatgpt-account-footer").firstElementChild, byTest("chatgpt-account-actions"));
|
||||
assert.equal(byTest("chatgpt-account-footer").lastElementChild?.lastElementChild, button("Close"));
|
||||
assert(!byTest("chatgpt-account-footer").contains(byTest("chatgpt-add-accounts")));
|
||||
await click(byTest("chatgpt-disconnect")); assert.equal(f.calls.disconnect.length, 0);
|
||||
assert.equal(byTest("chatgpt-account-dialog").querySelector("h2")?.textContent, "ChatGPT");
|
||||
assert(byTest("chatgpt-disconnect-confirmation").textContent?.includes(CHATGPT_DISCONNECT_COPY.title));
|
||||
assert(byTest("chatgpt-disconnect-confirmation").textContent?.includes(CHATGPT_DISCONNECT_COPY.body));
|
||||
assert(!document.body.textContent?.includes("delete your OpenAI"));
|
||||
@@ -208,8 +231,9 @@ for (const count of [1, 3]) test(`Add accounts reuses the actual add flow with $
|
||||
const initial = connected(count === 3);
|
||||
initial.accounts = [account(), ...Array.from({ length: count - 1 }, (_, i) => account(`saved-${i}`, `saved-${i}@example.invalid`))];
|
||||
const f = fixture(initial); const before = structuredClone(initial); await mount(); await click(button("Manage…"));
|
||||
assert.deepEqual([...byTest("chatgpt-account-footer").querySelectorAll("button")].map((b) => b.textContent), ["Add accounts", "Close"]);
|
||||
assert.equal(byTest("chatgpt-add-accounts").getAttribute("data-variant"), "outline");
|
||||
assert.deepEqual([...byTest("chatgpt-account-footer").querySelectorAll("button")].map((b) => b.textContent), ["Disconnect", "Close"]);
|
||||
assert.equal(byTest("chatgpt-add-accounts").getAttribute("data-variant"), "ghost");
|
||||
assert.equal(byTest("chatgpt-add-accounts").parentElement, byTest("chatgpt-account-strip"));
|
||||
assert.match(byTest("chatgpt-add-accounts").className, /cursor-pointer/);
|
||||
await click(byTest("chatgpt-add-accounts"));
|
||||
assert(byTest("chatgpt-privacy-notice").textContent?.includes(CHATGPT_NOTICE)); assert(byTest("chatgpt-proceed"));
|
||||
@@ -226,6 +250,68 @@ for (const count of [1, 3]) test(`Add accounts reuses the actual add flow with $
|
||||
assert.deepEqual(f.status, before); assert.equal(f.calls.cancel, 1); assert.deepEqual(f.calls.consent, []); assert.deepEqual(f.calls.disconnect, []);
|
||||
});
|
||||
|
||||
test("ChatGPT Sign in again is reserved for a saved disconnected account and keeps its existing registration flow", async () => {
|
||||
const f = fixture(connected(false)); await mount(); await click(button("Manage…"));
|
||||
assert(!button("Sign in again")); await selectAccount("Manage ChatGPT account", "signed-out-record"); assert(button("Sign in again"));
|
||||
await click(button("Sign in again")); assert.equal(byTest("chatgpt-account-dialog").querySelector("h2")?.textContent, "ChatGPT");
|
||||
assert(byTest("chatgpt-privacy-notice")); assert(toggle()); assert.equal(toggle().getAttribute("aria-checked"), "false");
|
||||
assert.equal(document.querySelector<HTMLSelectElement>('select[aria-label="Saved ChatGPT account"]')?.value, "signed-out-record"); assert.deepEqual(f.calls.login, []);
|
||||
await click(byTest("chatgpt-proceed")); assert.deepEqual(f.calls.login, ["signed-out-record"]); assert.equal(f.status.activeId, "private-registration-id");
|
||||
await click(button("Cancel", byTest("chatgpt-account-dialog"))); assert.equal(f.calls.cancel, 1); assert.deepEqual(f.calls.disconnect, []); assert.deepEqual(f.calls.consent, []);
|
||||
});
|
||||
test("actual controller storage-write failure is not ChatGPT authorization recovery even while available stays true", async () => {
|
||||
const f = recoveryController({ writeFails: true });
|
||||
try {
|
||||
await mount(); await click(button("Manage…")); assert(!button("Sign in again")); await click(toggle());
|
||||
const status = await f.service.status(); assert.equal(status.available, true); assert.equal(status.pending, false); assert.equal(status.accounts[0].connected, false); assert.equal(status.accounts[0].planEnabled, false); assert.equal(status.error, storageError);
|
||||
assert(!button("Sign in again"), "a failed credential write must not offer reauthorization");
|
||||
assert.equal(status.accounts[0].canSignInAgain, false); assert.equal(f.browserAttempts, 0); assert.equal(f.tokenRequests, 0);
|
||||
} finally { await act(async () => f.service.close()); }
|
||||
});
|
||||
for (const tokens of ["missing", "restricted", "scope"] as const) test(`actual controller ${tokens} authorization permits recovery, but paused/closed state does not`, async () => {
|
||||
const f = recoveryController({ tokens });
|
||||
try {
|
||||
await mount(); await click(byTest("chatgpt-login")); await selectAccount("Saved ChatGPT account", "fixture"); await click(button("Manage…"));
|
||||
assert.equal((await f.service.status()).accounts[0].canSignInAgain, true); assert(button("Sign in again"));
|
||||
assert.equal(f.saved.registrations[0].canSignInAgain, undefined, "eligibility is projected, never persisted");
|
||||
await act(async () => f.service.pause()); assert.equal((await f.service.status()).accounts[0].canSignInAgain, false); assert(!button("Sign in again"));
|
||||
await act(async () => f.service.resume()); assert.equal((await f.service.status()).accounts[0].canSignInAgain, true); assert(button("Sign in again"));
|
||||
await act(async () => f.service.close()); assert.equal((await f.service.status()).accounts[0].canSignInAgain, false); assert(!button("Sign in again"));
|
||||
assert.equal(f.browserAttempts, 0); assert.equal(f.tokenRequests, 0);
|
||||
} finally { await act(async () => f.service.close()); }
|
||||
});
|
||||
test("storage denial overrides an otherwise eligible remembered ChatGPT authorization recovery", async () => {
|
||||
const f = recoveryController({ tokens: "missing", writeFails: true });
|
||||
try {
|
||||
await mount(); await click(byTest("chatgpt-login")); await selectAccount("Saved ChatGPT account", "fixture"); await click(button("Manage…")); assert(button("Sign in again"));
|
||||
await click(toggle()); const status = await f.service.status(); assert.equal(status.available, true); assert.equal(status.error, storageError); assert.equal(status.accounts[0].canSignInAgain, false); assert(!button("Sign in again"));
|
||||
assert.equal(f.browserAttempts, 0); assert.equal(f.tokenRequests, 0);
|
||||
} finally { await act(async () => f.service.close()); }
|
||||
});
|
||||
test("actual invalid_grant recovery preserves remembered identity and stages the existing registration flow", async () => {
|
||||
const f = recoveryController({ tokens: "expired" });
|
||||
try {
|
||||
await mount(); await click(button("Manage…")); assert.equal((await f.service.status()).accounts[0].canSignInAgain, false); assert(!button("Sign in again"));
|
||||
await act(async () => { await f.service.models(); });
|
||||
const status = await f.service.status(); assert.equal(status.available, true); assert.equal(status.accounts[0].connected, false); assert.equal(status.accounts[0].canSignInAgain, true);
|
||||
assert.equal(status.accounts[0].id, "fixture"); assert.equal(status.accounts[0].registration, "oaiapp_fixture"); assert.equal(f.saved.registrations[0].tokens, undefined);
|
||||
await click(button("Sign in again")); assert(byTest("chatgpt-privacy-notice")); assert(toggle()); assert.equal(document.querySelector<HTMLSelectElement>('select[aria-label="Saved ChatGPT account"]')?.value, "fixture");
|
||||
assert.equal(f.tokenRequests, 1, "only the injected refresh response was requested"); assert.equal(f.browserAttempts, 0, "staging recovery never starts OAuth");
|
||||
} finally { await act(async () => f.service.close()); }
|
||||
});
|
||||
test("missing eligibility remains compatible but never implies authorization recovery; malformed eligibility is rejected", async () => {
|
||||
const initial = connected(); initial.accounts = [account()]; const f = fixture(initial); await mount(); await click(button("Manage…"));
|
||||
const legacy: ChatGPTStatus = { ...initial, accounts: initial.accounts.map(({ canSignInAgain: _ignored, ...a }) => ({ ...a, connected: false, planEnabled: false })) };
|
||||
await act(async () => f.push(legacy)); assert(!button("Sign in again")); assert(!document.querySelector('[role="alert"]'));
|
||||
for (const value of [null, "true", 1]) {
|
||||
await act(async () => f.push({ ...legacy, accounts: legacy.accounts.map((a) => ({ ...a, canSignInAgain: value })) } as unknown as ChatGPTStatus));
|
||||
assert(!button("Sign in again")); assert.equal(document.querySelector('[role="alert"]')?.textContent, "The ChatGPT connection could not be changed. Try again.");
|
||||
}
|
||||
});
|
||||
test("connected setup row retains the email dropdown before muted Manage", async () => {
|
||||
fixture(connected()); await mount(<ChatGPTConnection setup />);
|
||||
const row = byTest("chatgpt-connection").firstElementChild!; assert.equal(row.children[1]!.tagName, "SELECT"); assert.equal(row.children[2]!.textContent, "Manage…"); assert.match(row.children[2]!.className, /text-muted-foreground/);
|
||||
});
|
||||
test("Add accounts reuses the existing dialog's initial focus for the pre-auth transition", async () => {
|
||||
const previous = HTMLElement.prototype.focus; const focused: HTMLElement[] = [];
|
||||
HTMLElement.prototype.focus = function () { focused.push(this); };
|
||||
@@ -261,7 +347,7 @@ test("confirmed disconnect preserves identity/pending content until outcome, pre
|
||||
});
|
||||
|
||||
test("signed-out legacy accounts remain manageable/forgettable without pretending selection is a usable connection", async () => {
|
||||
const f = fixture(); await mount(); assert.equal(byTest("chatgpt-connection").textContent, "Continue with ChatGPT");
|
||||
const f = fixture(); await mount(); assert.equal(byTest("chatgpt-connection").textContent, "ChatGPTConnect");
|
||||
await click(byTest("chatgpt-login")); await selectAccount("Saved ChatGPT account", "signed-out-record"); await click(button("Manage…"));
|
||||
assert(byTest("chatgpt-managed-identity").textContent?.includes("signed-out@example.invalid"));
|
||||
assert.deepEqual(f.calls.select, []); assert.equal(f.calls.login.length, 0); await confirmDisconnect();
|
||||
|
||||
@@ -57,7 +57,7 @@ test("plan plus unknown graph rows keep the existing unknown suffix without fabr
|
||||
await act(async () => { document.querySelector("button")!.click(); });
|
||||
const recent = [...document.querySelectorAll(".border-t .shrink-0.tabular-nums")];
|
||||
assert.equal(recent.length, 2);
|
||||
for (const row of recent) assert.equal(row.textContent, "ChatGPT plan +1 unpriced", "both recent two-stage runs disclose their unknown stage");
|
||||
for (const row of recent) assert.equal(row.textContent, "ChatGPT +1 unpriced", "both recent two-stage runs disclose their unknown stage");
|
||||
assert.equal((document.body.textContent?.match(/\+1 unpriced/g) ?? []).length, 4, "both mixed stage rows also retain their unknown suffix");
|
||||
assert(!document.body.textContent?.includes("$0.00"));
|
||||
});
|
||||
|
||||
@@ -13,12 +13,12 @@ test("subscription usage is neither API dollars nor an unpriced-model mistake, i
|
||||
assert.equal(callCost(step, plan.model), null);
|
||||
assert.equal(turnCost(turn(plan.model, [])), null);
|
||||
assert.equal(jobCost({ usage: { model: plan.model, ...step, steps: 1 } }), null);
|
||||
assert.deepEqual(sessionCost({ turns: [plan, turn(plan.model, [])] }), { cost: 0, unpriced: 0, subscription: 2, turnCosts: { "scratch-turn": null } });
|
||||
assert.deepEqual(sessionCost({ turns: [plan, turn(plan.model, [])] }), { cost: 0, unpriced: 0, subscription: 2, subscriptionProviders: ["ChatGPT"], turnCosts: { "scratch-turn": null } });
|
||||
const api = turn("openai:gpt-5");
|
||||
const mixed = sessionCost({ turns: [plan, api, turn("unknown:missing")] });
|
||||
assert.equal(mixed.cost, turnCost(api)); assert.equal(mixed.unpriced, 1); assert.equal(mixed.subscription, 1);
|
||||
assert.equal(planCostLabel(null, true), "ChatGPT plan");
|
||||
assert.match(planCostLabel(mixed.cost, true), /API \+ ChatGPT plan$/);
|
||||
assert.equal(planCostLabel(null, true), "ChatGPT");
|
||||
assert.match(planCostLabel(mixed.cost, true), /\$[\d.]+ \+ ChatGPT$/);
|
||||
});
|
||||
|
||||
test("graph extraction and audit preserve subscription identity separately from priced and unknown stages", () => {
|
||||
|
||||
@@ -78,8 +78,9 @@ describe("desktop window chrome", () => {
|
||||
runInNewContext(readFileSync(new URL("../electron/preload.cjs", import.meta.url), "utf8"), context);
|
||||
assert.equal(bridge.platform, "win32");
|
||||
assert.deepEqual(Object.keys(bridge).sort(), [
|
||||
"chatgpt", "expandWindow", "getZoom", "installUpdate", "onUpdateStatus", "onZoom", "platform",
|
||||
"chatgpt", "expandWindow", "getZoom", "installUpdate", "onUpdateStatus", "onZoom", "platform", "subscriptions",
|
||||
]);
|
||||
assert.deepEqual(Object.keys(bridge.subscriptions as object).sort(), ["cancel", "consent", "disconnect", "login", "onChange", "open", "respond", "retry", "status"]);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -34,8 +34,8 @@ test("known zero, mixed priced/unknown, and subscription headline semantics stay
|
||||
assert.ok(mixed.totalDollars > 0);
|
||||
assert.match(headline([priced, historical]), new RegExp(`>\\$${mixed.totalDollars.toFixed(2).replace(".", "\\.")}<\\/button>`));
|
||||
const plan = run([usage()], "chatgpt:fixture");
|
||||
assert.match(headline([plan]), />ChatGPT plan<\/button>/);
|
||||
assert.match(headline([plan, zero]), />\$0\.00 API \+ ChatGPT plan<\/button>/);
|
||||
assert.match(headline([plan]), />ChatGPT<\/button>/);
|
||||
assert.match(headline([plan, zero]), />\$0\.00 \+ ChatGPT<\/button>/);
|
||||
});
|
||||
|
||||
test("graph stage and recent-run aggregates preserve known one-hour cache writes", () => {
|
||||
|
||||
@@ -47,6 +47,8 @@ describe("standalone JavaScript tracing exclusions and native workaround policy"
|
||||
"node_modules/@napi-rs/canvas-*/**/*",
|
||||
"node_modules/@resvg/resvg-js-*/**/*",
|
||||
"node_modules/@earendil-works/pi-ai/dist/providers/data/**/*",
|
||||
"node_modules/@earendil-works/pi-ai/dist/auth/oauth/{load,github-copilot,xai,device-code}.js",
|
||||
"node_modules/@earendil-works/pi-ai/dist/utils/sleep.js",
|
||||
"node_modules/@earendil-works/pi-agent-core/dist/**/*.json",
|
||||
"node_modules/gcp-metadata/**/*",
|
||||
"node_modules/json-bigint/**/*",
|
||||
|
||||
@@ -0,0 +1,221 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { createRequire } from "node:module";
|
||||
import { createModels, type OAuthCredential, type ProviderAuthInteraction } from "@earendil-works/pi-ai";
|
||||
import { githubCopilotProvider } from "@earendil-works/pi-ai/providers/github-copilot";
|
||||
import { xaiProvider } from "@earendil-works/pi-ai/providers/xai";
|
||||
import type { NativeSubscriptionScope } from "../src/core/contracts/subscriptions.ts";
|
||||
const require = createRequire(import.meta.url);
|
||||
const { createSubscriptions, loadSubscriptionSDK, validEndpoint, publicError } = require("../electron/subscriptions.cjs");
|
||||
const { fileStore, validateState } = require("../electron/subscription-store.cjs");
|
||||
const { subscriptionControl } = require("../electron/subscription-ipc.cjs");
|
||||
const ids = ["github-copilot", "xai"] as const;
|
||||
const credential = (id: string, expires = Date.now() + 3600000): OAuthCredential => ({ type: "oauth", access: id === "xai" ? "fixture-xai-access" : "tid=fixture;proxy-ep=proxy.individual.githubcopilot.com;", refresh: "fixture-refresh", expires, ...(id === "github-copilot" ? { availableModelIds: [githubCopilotProvider().getModels()[0]!.id] } : {}) });
|
||||
function deferred<T>() { let resolve!: (value: T) => void; const promise = new Promise<T>((done) => { resolve = done; }); return { resolve, promise }; }
|
||||
async function fixture(options: { login?: (id: string, interaction: ProviderAuthInteraction) => Promise<OAuthCredential>; refresh?: (id: string, value: OAuthCredential, signal: AbortSignal) => Promise<OAuthCredential>; stores?: Record<string, unknown> } = {}) {
|
||||
const directory = await fs.mkdtemp(path.join(os.tmpdir(), "lh-subscription-auth-"));
|
||||
let refreshes = 0;
|
||||
const factory = (id: string) => {
|
||||
const native = id === "xai" ? xaiProvider() : githubCopilotProvider();
|
||||
return { ...native, auth: { ...native.auth, oauth: { ...native.auth.oauth!, login: (interaction: ProviderAuthInteraction) => options.login?.(id, interaction) ?? Promise.resolve(credential(id)), refresh: async (value: OAuthCredential, signal: AbortSignal) => { refreshes++; return options.refresh?.(id, value, signal) ?? { ...credential(id), refresh: "fixture-rotated" }; } } } };
|
||||
};
|
||||
const sdk = { createModels, githubCopilotProvider: () => factory("github-copilot"), xaiProvider: () => factory("xai") };
|
||||
const updates: any[] = [], opened: string[] = [];
|
||||
const controller = createSubscriptions({ directory, sdk, stores: options.stores, openExternal: async (url: string) => { opened.push(url); }, onChange: (snapshot: unknown) => updates.push(snapshot) });
|
||||
await controller.ready;
|
||||
const scope = async (id: string): Promise<NativeSubscriptionScope> => {
|
||||
const snapshot = await controller.snapshot(), status = snapshot.statuses.find((s: any) => s.providerId === id), model = snapshot.models[id][0];
|
||||
return { providerId: id as NativeSubscriptionScope["providerId"], connectionId: status.connectionId, epoch: status.epoch, authorityRevision: status.authorityRevision, selectedModelId: model.id };
|
||||
};
|
||||
return { controller, directory, sdk, opened, updates, scope, get refreshes() { return refreshes; }, async cleanup() { controller.close(); await fs.rm(directory, { recursive: true, force: true }); } };
|
||||
}
|
||||
test("main loader resolves Pi's import-only PUBLIC package exports and native OAuth without a CLI or network", async () => {
|
||||
const sdk = await loadSubscriptionSDK(path.resolve(import.meta.dirname, ".."));
|
||||
assert.equal(typeof sdk.createModels, "function");
|
||||
for (const [id, factory] of [["github-copilot", sdk.githubCopilotProvider], ["xai", sdk.xaiProvider]] as const) {
|
||||
const provider = factory(), store = new sdk.InMemoryCredentialStore(); await store.modify(id, () => credential(id));
|
||||
const models = sdk.createModels({ credentials: store }); models.setProvider({ ...provider, auth: { oauth: provider.auth.oauth } });
|
||||
const resolved = await models.getAuth(provider.getModels()[0]); assert.equal(resolved.auth.apiKey, credential(id).access);
|
||||
}
|
||||
});
|
||||
for (const id of ids) test(`${id}: independent default-ON file, restart, explicit OAuth lease and local disconnect`, async () => {
|
||||
const f = await fixture();
|
||||
try {
|
||||
assert((await f.controller.status()).every((s: any) => s.available && !s.connected && s.allowBackgroundUse));
|
||||
await f.controller.login(id, false);
|
||||
const saved = JSON.parse(await fs.readFile(path.join(f.directory, `${id}-credentials.json`), "utf8"));
|
||||
assert.equal(saved.version, 1); assert.equal(saved.credential.type, "oauth"); assert.equal(saved.allowBackgroundUse, false);
|
||||
assert.equal((await fs.stat(path.join(f.directory, `${id}-credentials.json`))).mode & 0o777, 0o600);
|
||||
const publicState = JSON.stringify(await f.controller.snapshot());
|
||||
assert(!publicState.includes(saved.credential.access)); assert(!publicState.includes(saved.credential.refresh));
|
||||
const scope = await f.scope(id), result = await f.controller.lease(scope, false);
|
||||
assert.equal(result.auth.apiKey, saved.credential.access);
|
||||
if (id === "github-copilot") assert.equal(result.auth.baseUrl, "https://api.individual.githubcopilot.com");
|
||||
await assert.rejects(f.controller.lease(scope, true), (error: any) => error.code === "background");
|
||||
const reloaded = createSubscriptions({ directory: f.directory, sdk: f.sdk, openExternal: async () => {} }); await reloaded.ready;
|
||||
assert.equal((await reloaded.status()).find((s: any) => s.providerId === id).connectionId, saved.connectionId); reloaded.close();
|
||||
await f.controller.disconnect(id); await assert.rejects(f.controller.lease(scope, false));
|
||||
const removed = JSON.parse(await fs.readFile(path.join(f.directory, `${id}-credentials.json`), "utf8"));
|
||||
assert.deepEqual(removed, { version: 1, allowBackgroundUse: false });
|
||||
assert.equal((await f.controller.status()).find((s: any) => s.providerId !== id).connected, false);
|
||||
} finally { await f.cleanup(); }
|
||||
});
|
||||
test("parallel expired-token requests refresh once, preserve native extension metadata and durable rotation", async () => {
|
||||
const release = deferred<OAuthCredential>();
|
||||
const f = await fixture({ login: async (id) => credential(id, 0), refresh: () => release.promise });
|
||||
try {
|
||||
await f.controller.login("github-copilot", true); const scope = await f.scope("github-copilot");
|
||||
const first = f.controller.lease(scope, false), second = f.controller.lease(scope, false);
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
const next = { ...credential("github-copilot"), refresh: "rotated-single-writer", extraNativeField: "retained" }; release.resolve(next);
|
||||
const results = await Promise.all([first, second]); assert.equal(f.refreshes, 1); assert.equal(results[0].auth.apiKey, next.access);
|
||||
const saved = JSON.parse(await fs.readFile(path.join(f.directory, "github-copilot-credentials.json"), "utf8"));
|
||||
assert.equal(saved.credential.refresh, "rotated-single-writer"); assert.equal(saved.credential.extraNativeField, "retained");
|
||||
} finally { await f.cleanup(); }
|
||||
});
|
||||
test("background changes retain foreground scope; replacement and disconnect invalidate old leases", async () => {
|
||||
const f = await fixture();
|
||||
try {
|
||||
await f.controller.login("xai", true); const scope = await f.scope("xai");
|
||||
await f.controller.consent("xai", false); await f.controller.lease(scope, false); await assert.rejects(f.controller.lease(scope, true));
|
||||
await f.controller.login("xai", false); assert.notEqual((await f.scope("xai")).connectionId, scope.connectionId); await assert.rejects(f.controller.lease(scope, false));
|
||||
} finally { await f.cleanup(); }
|
||||
});
|
||||
test("device interaction exposes only public code and opaque browser action; cancellation cannot commit late success", async () => {
|
||||
const done = deferred<OAuthCredential>();
|
||||
const f = await fixture({ login: async (_id, interaction) => { interaction.notify({ type: "device_code", userCode: "PUBLIC-CODE", verificationUri: "https://auth.x.ai/device?user_code=PUBLIC-CODE" }); return done.promise; } });
|
||||
try {
|
||||
const pending = f.controller.login("xai", true); await new Promise((resolve) => setImmediate(resolve));
|
||||
const status = (await f.controller.status()).find((s: any) => s.providerId === "xai"), interaction = status.interaction;
|
||||
assert.equal(interaction.userCode, "PUBLIC-CODE"); assert(!JSON.stringify(status).includes("device_code"));
|
||||
await f.controller.open("xai", interaction.attemptId, interaction.browser.id); assert.equal(f.opened.length, 1);
|
||||
await assert.rejects(f.controller.open("xai", "old", interaction.browser.id));
|
||||
f.controller.cancel("xai"); done.resolve(credential("xai")); await pending;
|
||||
assert.equal((await f.controller.status()).find((s: any) => s.providerId === "xai").connected, false);
|
||||
await assert.rejects(fs.readFile(path.join(f.directory, "xai-credentials.json")), (error: any) => error.code === "ENOENT");
|
||||
} finally { await f.cleanup(); }
|
||||
});
|
||||
test("cancelled replacement retains old credentials; late old completion cannot overwrite a newer login", async () => {
|
||||
let count = 0; const old = deferred<OAuthCredential>();
|
||||
const f = await fixture({ login: async (id) => ++count === 2 ? old.promise : credential(id) });
|
||||
try {
|
||||
await f.controller.login("xai", true); const first = await f.scope("xai");
|
||||
const pending = f.controller.login("xai", true); await new Promise((resolve) => setImmediate(resolve)); f.controller.cancel("xai");
|
||||
assert.equal((await f.scope("xai")).connectionId, first.connectionId);
|
||||
await f.controller.login("xai", true); const replacement = await f.scope("xai");
|
||||
old.resolve(credential("xai")); await pending;
|
||||
assert.equal((await f.scope("xai")).connectionId, replacement.connectionId);
|
||||
} finally { await f.cleanup(); }
|
||||
});
|
||||
for (const id of ids) test(`${id}: cancelled sign-in discards draft consent, and failed replacement retains the working connection`, async () => {
|
||||
let count = 0; const late = deferred<OAuthCredential>();
|
||||
const f = await fixture({ login: async (provider) => { count++; if (count === 1) return late.promise; if (count === 3) throw new Error("private-provider-login-error"); return credential(provider); } });
|
||||
try {
|
||||
const pending = f.controller.login(id, false); await new Promise((resolve) => setImmediate(resolve)); f.controller.cancel(id); late.resolve(credential(id)); await pending;
|
||||
assert.equal((await f.controller.status()).find((s: any) => s.providerId === id).allowBackgroundUse, true);
|
||||
await assert.rejects(fs.readFile(path.join(f.directory, `${id}-credentials.json`)), (error: any) => error.code === "ENOENT");
|
||||
await f.controller.login(id); const scope = await f.scope(id); const original = await fs.readFile(path.join(f.directory, `${id}-credentials.json`), "utf8");
|
||||
await assert.rejects(f.controller.login(id, false));
|
||||
assert.equal(await fs.readFile(path.join(f.directory, `${id}-credentials.json`), "utf8"), original);
|
||||
const status = (await f.controller.status()).find((s: any) => s.providerId === id); assert(status.connected); assert(status.allowBackgroundUse); assert(!JSON.stringify(status).includes("private-provider-login-error"));
|
||||
await f.controller.lease(scope, false);
|
||||
} finally { await f.cleanup(); }
|
||||
});
|
||||
test("Copilot native enterprise prompt is answered only through the current opaque prompt action", async () => {
|
||||
const f = await fixture({ login: async (id, interaction) => { const domain = await interaction.prompt({ type: "text", message: "GitHub domain", placeholder: "github.com" }); return { ...credential(id), enterpriseUrl: domain }; } });
|
||||
try {
|
||||
const pending = f.controller.login("github-copilot", false); await new Promise((resolve) => setImmediate(resolve));
|
||||
const status = (await f.controller.status()).find((s: any) => s.providerId === "github-copilot"), { attemptId, prompt } = status.interaction;
|
||||
assert.equal(prompt.type, "text"); assert.equal(prompt.message, "GitHub domain");
|
||||
await assert.rejects(f.controller.respond("github-copilot", "stale", prompt.id, "company.ghe.com"));
|
||||
await f.controller.respond("github-copilot", attemptId, prompt.id, "company.ghe.com"); await pending;
|
||||
const saved = JSON.parse(await fs.readFile(path.join(f.directory, "github-copilot-credentials.json"), "utf8")); assert.equal(saved.credential.enterpriseUrl, "company.ghe.com"); assert.equal(saved.allowBackgroundUse, false);
|
||||
assert(!(await f.controller.status()).find((s: any) => s.providerId === "github-copilot").interaction);
|
||||
} finally { await f.cleanup(); }
|
||||
});
|
||||
test("account endpoint rotation invalidates older scopes without losing the durable refreshed credential", async () => {
|
||||
const next = { ...credential("github-copilot"), access: "tid=fixture;proxy-ep=proxy.business.githubcopilot.com;" };
|
||||
const f = await fixture({ login: async (id) => credential(id, 0), refresh: async () => next });
|
||||
try {
|
||||
await f.controller.login("github-copilot"); const old = await f.scope("github-copilot"); await assert.rejects(f.controller.lease(old, false));
|
||||
const scope = await f.scope("github-copilot"); assert(scope.authorityRevision > old.authorityRevision); assert.equal(scope.connectionId, old.connectionId);
|
||||
assert.equal((await f.controller.lease(scope, false)).auth.baseUrl, "https://api.business.githubcopilot.com"); assert.equal(f.refreshes, 1);
|
||||
assert.equal(JSON.parse(await fs.readFile(path.join(f.directory, "github-copilot-credentials.json"), "utf8")).credential.access, next.access);
|
||||
} finally { await f.cleanup(); }
|
||||
});
|
||||
test("a malformed returned rotation fails closed and cannot retry with the pre-rotation credential", async () => {
|
||||
const f = await fixture({ login: async (id) => credential(id, 0), refresh: async (id) => ({ ...credential(id), refresh: "rotated-but-malformed", expires: NaN }) });
|
||||
try {
|
||||
await f.controller.login("xai"); const scope = await f.scope("xai"); await assert.rejects(f.controller.lease(scope, false));
|
||||
await assert.rejects(f.controller.lease(scope, false)); await assert.rejects(f.controller.retry("xai")); assert.equal(f.refreshes, 1);
|
||||
assert(!(await f.controller.status()).find((s: any) => s.providerId === "xai").available);
|
||||
} finally { await f.cleanup(); }
|
||||
});
|
||||
test("renderer controls cannot obtain a request lease or internal snapshot", async () => {
|
||||
const f = await fixture();
|
||||
try { for (const op of ["lease", "snapshot", "read", "getAuth"]) await assert.rejects(subscriptionControl({ op }, f.controller)); }
|
||||
finally { await f.cleanup(); }
|
||||
});
|
||||
test("corrupt files isolate providers and are preserved; malformed Copilot model metadata never grants all models", async () => {
|
||||
const f = await fixture();
|
||||
try {
|
||||
const file = path.join(f.directory, "github-copilot-credentials.json"); await fs.writeFile(file, "{broken", { mode: 0o600 });
|
||||
const reloaded = createSubscriptions({ directory: f.directory, sdk: f.sdk, openExternal: async () => {} }); await reloaded.ready;
|
||||
const statuses = await reloaded.status(); assert(!statuses.find((s: any) => s.providerId === "github-copilot").available); assert(statuses.find((s: any) => s.providerId === "xai").available);
|
||||
await assert.rejects(reloaded.login("github-copilot", true)); assert.equal(await fs.readFile(file, "utf8"), "{broken"); reloaded.close();
|
||||
assert.throws(() => validateState("github-copilot", { version: 1, allowBackgroundUse: true, connectionId: "b5ac22b7-dfe3-4e52-ae0a-6da2990d4aef", credential: { ...credential("github-copilot"), availableModelIds: [123] } }));
|
||||
} finally { await f.cleanup(); }
|
||||
});
|
||||
test("failed rotation persistence blocks subsequent requests instead of using old tokens", async () => {
|
||||
let state: any = { version: 1, allowBackgroundUse: true }, fail = false;
|
||||
const stores = { xai: { read: async () => state, write: async (next: any) => { if (fail) throw new Error("private-disk-detail"); state = structuredClone(next); } } };
|
||||
const f = await fixture({ stores, login: async (id) => credential(id, 0) });
|
||||
try {
|
||||
await f.controller.login("xai", true); const scope = await f.scope("xai"); fail = true;
|
||||
await assert.rejects(f.controller.lease(scope, false)); assert.equal(f.refreshes, 1);
|
||||
await assert.rejects(f.controller.lease(scope, false)); assert.equal(f.refreshes, 1);
|
||||
const status = (await f.controller.status()).find((s: any) => s.providerId === "xai"); assert(!status.available); assert(!JSON.stringify(status).includes("private-disk-detail"));
|
||||
} finally { await f.cleanup(); }
|
||||
});
|
||||
test("disconnect during refresh cannot resurrect credentials or replace disconnect status with a stale error", async () => {
|
||||
const rotation = deferred<OAuthCredential>(); const f = await fixture({ login: async (id) => credential(id, 0), refresh: () => rotation.promise });
|
||||
try {
|
||||
await f.controller.login("xai", true); const scope = await f.scope("xai");
|
||||
const lease = f.controller.lease(scope, false).then(() => false, () => true);
|
||||
await new Promise((resolve) => setImmediate(resolve)); const disconnect = f.controller.disconnect("xai"); rotation.resolve({ ...credential("xai"), refresh: "do-not-resurrect" });
|
||||
await disconnect; assert(await lease);
|
||||
const status = (await f.controller.status()).find((s: any) => s.providerId === "xai"); assert.equal(status.connectionId, null); assert.equal(status.error, undefined);
|
||||
assert.deepEqual(JSON.parse(await fs.readFile(path.join(f.directory, "xai-credentials.json"), "utf8")), { version: 1, allowBackgroundUse: true });
|
||||
} finally { await f.cleanup(); }
|
||||
});
|
||||
test("pause during refresh saves returned rotation but refuses the request; resume uses durable refreshed state", async () => {
|
||||
const rotation = deferred<OAuthCredential>(); const f = await fixture({ login: async (id) => credential(id, 0), refresh: () => rotation.promise });
|
||||
try {
|
||||
await f.controller.login("xai", true); const scope = await f.scope("xai"); const lease = f.controller.lease(scope, false).then(() => false, () => true);
|
||||
await new Promise((resolve) => setImmediate(resolve)); f.controller.pause(); rotation.resolve({ ...credential("xai"), refresh: "rotation-survives-pause" }); assert(await lease);
|
||||
const saved = JSON.parse(await fs.readFile(path.join(f.directory, "xai-credentials.json"), "utf8")); assert.equal(saved.credential.refresh, "rotation-survives-pause");
|
||||
f.controller.resume(); await f.controller.lease(await f.scope("xai"), false); assert.equal(f.refreshes, 1);
|
||||
} finally { await f.cleanup(); }
|
||||
});
|
||||
test("enterprise domains are preserved; derived endpoints and control errors are guarded", async () => {
|
||||
const value = { ...credential("github-copilot"), enterpriseUrl: "company.ghe.com" };
|
||||
validateState("github-copilot", { version: 1, allowBackgroundUse: true, connectionId: "b5ac22b7-dfe3-4e52-ae0a-6da2990d4aef", credential: value });
|
||||
assert(validEndpoint("github-copilot", { apiKey: "fixture", baseUrl: "https://copilot-api.company.ghe.com" }, value));
|
||||
for (const baseUrl of ["http://api.individual.githubcopilot.com", "https://attacker.example/", "https://user:pass@api.individual.githubcopilot.com", "https://api.individual.githubcopilot.com/path"]) assert(!validEndpoint("github-copilot", { apiKey: "fixture", baseUrl }, value));
|
||||
await assert.rejects(subscriptionControl({ op: "status" }, { status: async () => { throw new Error("private-token-response"); } }), (error: Error) => !error.message.includes("private-token-response"));
|
||||
assert(!publicError(new Error("private-token-response")).includes("private-token-response"));
|
||||
});
|
||||
test("atomic store failed rename preserves original and leaves no temporary files", async () => {
|
||||
const f = await fixture();
|
||||
try {
|
||||
const file = path.join(f.directory, "scratch.json"), state = { version: 1, allowBackgroundUse: false };
|
||||
await fileStore(file, "xai").write(state);
|
||||
const io = { ...fs, rename: async () => { throw new Error("fixture-rename-failure"); } };
|
||||
await assert.rejects(fileStore(file, "xai", io).write({ version: 1, allowBackgroundUse: true }));
|
||||
assert.deepEqual(JSON.parse(await fs.readFile(file, "utf8")), state);
|
||||
assert.deepEqual((await fs.readdir(f.directory)).filter((name) => name.endsWith(".tmp")), []);
|
||||
} finally { await f.cleanup(); }
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { after, before, test } from "node:test";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { Agent, type AgentMessage } from "@earendil-works/pi-agent-core";
|
||||
import { AssistantMessageEventStream } from "@earendil-works/pi-ai/utils/event-stream";
|
||||
import { piModels } from "../src/agent/models.ts";
|
||||
import { toLlmMessages } from "../src/core/native-messages.ts";
|
||||
import { buildSubagentSteer } from "../src/core/contracts.ts";
|
||||
import { NATIVE_SUBSCRIPTIONS } from "../src/core/contracts/subscriptions.ts";
|
||||
const directory = fs.mkdtempSync(path.join(os.tmpdir(), "lh-subscription-background-"));
|
||||
process.env.LH_DATA_DIR = path.join(directory, "data"); process.env.LH_AUTH_FILE = path.join(directory, "auth.json"); fs.writeFileSync(process.env.LH_AUTH_FILE, "{}");
|
||||
const { stores } = await import("../src/server/stores.ts");
|
||||
const { beginTurn, endTurn } = await import("../src/server/turn-liveness.ts");
|
||||
const { deliverJobResult, registerLiveAgent, unregisterLiveAgent, setTurnRunner, settleSteers, steerLiveChat, automaticFollowUpAllowed } = await import("../src/server/jobs.ts");
|
||||
const { persistTurn } = await import("../src/server/turn-service.ts");
|
||||
const originalSend = Object.getOwnPropertyDescriptor(process, "send"), originalConnected = Object.getOwnPropertyDescriptor(process, "connected");
|
||||
const marker = process.env.LH_DESKTOP_SERVER, secret = process.env.LH_DESKTOP_API_SECRET;
|
||||
let revision = 0;
|
||||
function connection(providerId: string, background: boolean) {
|
||||
revision++;
|
||||
(process.emit as (event: string, value: unknown) => boolean)("message", { channel: "lh:subscriptions", op: "changed", snapshot: {
|
||||
statuses: NATIVE_SUBSCRIPTIONS.map((p) => ({ providerId: p.providerId, available: true, connected: true, pending: false, allowBackgroundUse: p.providerId === providerId ? background : true, connectionId: `fixture-${p.providerId}`, epoch: 1, authorityRevision: 1, revision })),
|
||||
models: Object.fromEntries(NATIVE_SUBSCRIPTIONS.map((p) => [p.providerId, piModels().getModels(p.providerId)])),
|
||||
} });
|
||||
}
|
||||
before(() => {
|
||||
process.env.LH_DESKTOP_SERVER = "1"; process.env.LH_DESKTOP_API_SECRET = "d".repeat(64);
|
||||
Object.defineProperty(process, "connected", { configurable: true, value: true });
|
||||
Object.defineProperty(process, "send", { configurable: true, value: (_message: unknown, callback?: (error: Error) => void) => { callback?.(new Error("unexpected fixture request")); return false; } });
|
||||
});
|
||||
after(() => {
|
||||
fs.rmSync(directory, { recursive: true, force: true });
|
||||
if (originalSend) Object.defineProperty(process, "send", originalSend); else delete process.send;
|
||||
if (originalConnected) Object.defineProperty(process, "connected", originalConnected); else delete (process as { connected?: boolean }).connected;
|
||||
if (marker === undefined) delete process.env.LH_DESKTOP_SERVER; else process.env.LH_DESKTOP_SERVER = marker;
|
||||
if (secret === undefined) delete process.env.LH_DESKTOP_API_SECRET; else process.env.LH_DESKTOP_API_SECRET = secret;
|
||||
});
|
||||
for (const p of NATIVE_SUBSCRIPTIONS) for (const revokeAfterQueue of [false, true]) test(`${p.providerId}: foreground survives and automatic queue retains ownership with background ${revokeAfterQueue ? "revoked" : "off"}`, { timeout: 5000 }, async () => {
|
||||
connection(p.providerId, revokeAfterQueue);
|
||||
const chat = stores.chatStore.createChat("Fixture mixed queue"); const model = { ...piModels().getModels(p.providerId)[0]!, provider: p.logicalId };
|
||||
stores.chatStore.saveSettings(chat.id, { model: { provider: p.logicalId, id: model.id } });
|
||||
const job = stores.jobStore.createJob({ kind: "research", chatId: chat.id, toolCallId: "fixture-job-call" }); const result = { brief: "Fixture helper result", sources: [] };
|
||||
stores.jobStore.updateJob(job.id, { status: "running", result }); const steer = buildSubagentSteer(job, result); const contexts: string[] = [];
|
||||
const agent = new Agent({ initialState: { model, messages: [{ id: "fixture-initial", role: "user", content: "Initial learner input", timestamp: 1 } as AgentMessage] }, convertToLlm: toLlmMessages, streamFn: () => {
|
||||
contexts.push(JSON.stringify(agent.state.messages)); const stream = new AssistantMessageEventStream();
|
||||
const output = { role: "assistant" as const, provider: p.logicalId, api: model.api, model: model.id, content: [{ type: "text" as const, text: "Foreground response" }], stopReason: "stop" as const, timestamp: 2, usage: { input: 2, output: 1, cacheRead: 0, cacheWrite: 0, totalTokens: 3, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } } };
|
||||
stream.push({ type: "start", partial: output }); stream.push({ type: "done", reason: "stop", message: output }); stream.end(); return stream;
|
||||
} });
|
||||
let wakes = 0;
|
||||
setTurnRunner(async (chatId, input) => { wakes++; if (input.type === "steers") stores.chatStore.saveMessages(chatId, [...stores.chatStore.getChat(chatId)!.messages, ...input.steers]); return 200; });
|
||||
assert(beginTurn(chat.id)); registerLiveAgent(chat.id, agent); const delivered = deliverJobResult(steer);
|
||||
assert(steerLiveChat(chat.id, { id: "fixture-live-learner", role: "user", content: "Continue my learning", timestamp: 3 } as never));
|
||||
if (revokeAfterQueue) connection(p.providerId, false);
|
||||
assert(!automaticFollowUpAllowed(chat.id, agent)); await agent.continue();
|
||||
assert.equal(contexts.length, 1); assert(contexts[0]!.includes("Continue my learning")); assert(!contexts[0]!.includes(steer.id));
|
||||
const persisted = persistTurn(stores.chatStore, chat.id, 0, agent.state.messages, false);
|
||||
unregisterLiveAgent(chat.id); endTurn(chat.id); settleSteers(chat.id, persisted); await new Promise((resolve) => setImmediate(resolve));
|
||||
assert.equal(wakes, 0); assert.equal(stores.jobStore.getJob(job.id)!.status, "running");
|
||||
connection(p.providerId, true); await delivered; assert.equal(wakes, 1); assert.equal(stores.jobStore.getJob(job.id)!.status, "done");
|
||||
});
|
||||
@@ -0,0 +1,133 @@
|
||||
import React, { act } from "react";
|
||||
(globalThis as Record<string, unknown>).React = React;
|
||||
import assert from "node:assert/strict";
|
||||
import { afterEach, test } from "node:test";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { parseHTML } from "linkedom";
|
||||
import { NativeSubscriptionConnection, NativeSubscriptionUIProvider, SubscriptionConnections, COPILOT_POLICY_NOTICE, emptySubscriptionStatus } from "../components/settings/subscription-connections.tsx";
|
||||
import { ChatGPTUIProvider } from "../components/settings/chatgpt-connection.tsx";
|
||||
import type { NativeSubscriptionControlBridge, NativeSubscriptionProviderId, NativeSubscriptionStatus } from "../src/core/contracts/subscriptions.ts";
|
||||
const dom = parseHTML("<html><body></body></html>");
|
||||
Object.assign(globalThis, { window: dom.window, document: dom.document, Element: dom.window.Element, HTMLElement: dom.window.HTMLElement, CustomEvent: dom.window.CustomEvent, IS_REACT_ACT_ENVIRONMENT: true });
|
||||
let root: Root | undefined, host: HTMLDivElement | undefined;
|
||||
function deferred() { let resolve!: () => void; const promise = new Promise<void>((done) => { resolve = done; }); return { promise, resolve }; }
|
||||
function fixture(connected = false) {
|
||||
let statuses: NativeSubscriptionStatus[] = ["github-copilot", "xai"].map((id) => ({ ...emptySubscriptionStatus(id as NativeSubscriptionProviderId), available: true, connected, connectionId: connected ? `local-${id}` : null }));
|
||||
const listeners = new Set<() => void>(), login = deferred();
|
||||
const calls = { login: [] as unknown[][], cancel: 0, open: [] as unknown[][], consent: [] as unknown[][], disconnect: [] as string[], retry: [] as string[] };
|
||||
function push(id: NativeSubscriptionProviderId, next: Partial<NativeSubscriptionStatus>) {
|
||||
statuses = statuses.map((s) => s.providerId === id ? { ...s, revision: s.revision + 1, ...next } : s); for (const listener of listeners) listener();
|
||||
}
|
||||
const bridge: NativeSubscriptionControlBridge = {
|
||||
status: async () => structuredClone(statuses),
|
||||
login: async (id, enabled) => { calls.login.push([id, enabled]); push(id, { pending: true, interaction: { attemptId: "attempt", userCode: "PUBLIC-CODE", browser: { id: "opaque-action", url: "https://auth.example/device" }, progress: "Complete sign-in in your browser." } }); await login.promise; },
|
||||
cancel: async (id) => { calls.cancel++; push(id, { pending: false, interaction: undefined }); },
|
||||
respond: async () => {}, open: async (...args) => { calls.open.push(args); }, retry: async (id) => { calls.retry.push(id); },
|
||||
disconnect: async (id) => { calls.disconnect.push(id); push(id, { connected: false, connectionId: null }); },
|
||||
consent: async (id, enabled) => { calls.consent.push([id, enabled]); push(id, { allowBackgroundUse: enabled }); },
|
||||
onChange: (listener) => { listeners.add(listener); return () => { listeners.delete(listener); }; },
|
||||
};
|
||||
window.lh = { expandWindow() {}, subscriptions: bridge };
|
||||
return { bridge, calls, login, push, get statuses() { return statuses; } };
|
||||
}
|
||||
async function mount() {
|
||||
host = document.createElement("div"); document.body.append(host); root = createRoot(host);
|
||||
await act(async () => root!.render(<NativeSubscriptionUIProvider><NativeSubscriptionConnection providerId="github-copilot" /><NativeSubscriptionConnection providerId="xai" /></NativeSubscriptionUIProvider>));
|
||||
}
|
||||
function maybeButton(text: string, scope: ParentNode = document) { return [...scope.querySelectorAll<HTMLButtonElement>("button")].find((element) => element.textContent === text); }
|
||||
function button(text: string, scope: ParentNode = document) { const found = maybeButton(text, scope); assert(found, `missing button ${text}`); return found; }
|
||||
const row = (id: string) => document.querySelector<HTMLElement>(`[data-testid="${id}-connection"]`)!;
|
||||
const dialog = () => document.querySelector<HTMLElement>('[data-testid="subscription-connection-dialog"]')!;
|
||||
const toggle = () => dialog().querySelector<HTMLElement>('[role="switch"]')!;
|
||||
const footer = () => dialog().querySelector<HTMLElement>('[data-testid="subscription-connection-footer"]')!;
|
||||
async function click(element: HTMLElement) { assert(element); await act(async () => element.click()); }
|
||||
async function connectionStep(id: string) { await click(button("Connect", row(id))); await click(button("Continue", dialog())); }
|
||||
afterEach(async () => { await act(async () => root?.unmount()); host?.remove(); root = undefined; host = undefined; delete window.lh; });
|
||||
|
||||
test("three compact rows render in approved order with no browser-only sign-in or fabricated identities", () => {
|
||||
const html = renderToStaticMarkup(<ChatGPTUIProvider><NativeSubscriptionUIProvider><SubscriptionConnections /></NativeSubscriptionUIProvider></ChatGPTUIProvider>);
|
||||
assert(html.indexOf('data-testid="chatgpt-connection"') < html.indexOf('data-testid="github-copilot-connection"'));
|
||||
assert(html.indexOf('data-testid="github-copilot-connection"') < html.indexOf('data-testid="xai-connection"'));
|
||||
assert.equal((html.match(/>Connect<\/button>/g) ?? []).length, 3);
|
||||
assert(!html.includes("@")); assert(!html.includes("Add accounts"));
|
||||
});
|
||||
for (const [id, label] of [["github-copilot", "GitHub Copilot"], ["xai", "Grok"]] as const) test(`${id}: disclosure and connection are separate steps with no duplicated content or early authorization`, async () => {
|
||||
const f = fixture(); await mount(); await click(button("Connect", row(id)));
|
||||
assert.equal(dialog().querySelector("h2")?.textContent, label);
|
||||
assert(dialog().querySelector('[data-testid="subscription-privacy-notice"]')); assert(!toggle());
|
||||
assert.equal(!!dialog().querySelector('[data-testid="copilot-policy-notice"]'), id === "github-copilot");
|
||||
if (id === "github-copilot") assert(dialog().textContent?.includes(COPILOT_POLICY_NOTICE));
|
||||
assert(!maybeButton("Connect", dialog())); assert.deepEqual(f.calls.login, []); assert.deepEqual(f.calls.consent, []);
|
||||
await click(button("Continue", dialog()));
|
||||
assert.equal(dialog().querySelector("h2")?.textContent, label); assert.equal(toggle().getAttribute("aria-checked"), "true");
|
||||
assert(!dialog().querySelector('[data-testid="subscription-privacy-notice"], [data-testid="copilot-policy-notice"]'));
|
||||
assert.equal(f.calls.login.length, 0); await click(toggle()); assert.deepEqual(f.calls.consent, []);
|
||||
await click(button("Connect", dialog())); assert.deepEqual(f.calls.login, [[id, false]]);
|
||||
assert(dialog().textContent?.includes("PUBLIC-CODE")); assert(!dialog().querySelector('[data-testid="subscription-privacy-notice"]'));
|
||||
await click(button("Open browser", dialog())); assert.deepEqual(f.calls.open, [[id, "attempt", "opaque-action"]]);
|
||||
await click(button("Cancel", dialog())); assert.equal(f.calls.cancel, 1); assert(!dialog());
|
||||
await click(button("Connect", row(id === "xai" ? "github-copilot" : "xai"))); const newer = dialog(); await act(async () => f.login.resolve());
|
||||
assert(dialog() === newer, "old completion cannot close another provider dialog");
|
||||
});
|
||||
for (const phase of ["notice", "connection"] as const) test(`cancelling the native ${phase} step starts no OAuth and saves no draft preference`, async () => {
|
||||
const f = fixture(); await mount(); await click(button("Connect", row("xai")));
|
||||
if (phase === "connection") { await click(button("Continue", dialog())); await click(toggle()); }
|
||||
await click(button("Cancel", dialog())); assert(!dialog()); assert.deepEqual(f.calls.login, []); assert.deepEqual(f.calls.consent, []); assert.equal(f.calls.cancel, 0);
|
||||
await connectionStep("xai"); assert.equal(toggle().getAttribute("aria-checked"), "true");
|
||||
});
|
||||
test("native step transition moves keyboard focus into the new connection controls", async () => {
|
||||
const original = HTMLElement.prototype.focus, focused: HTMLElement[] = [];
|
||||
HTMLElement.prototype.focus = function () { focused.push(this); };
|
||||
try { fixture(); await mount(); await connectionStep("xai"); assert.equal(focused.at(-1), toggle()); }
|
||||
finally { HTMLElement.prototype.focus = original; }
|
||||
});
|
||||
test("healthy management has only controls, quiet Manage, destructive action left and Close right", async () => {
|
||||
const f = fixture(true); await mount();
|
||||
assert(!row("xai").textContent?.includes("Connected")); assert.match(button("Manage…", row("xai")).className, /text-muted-foreground/);
|
||||
await click(button("Manage…", row("xai")));
|
||||
assert.equal(dialog().querySelector("h2")?.textContent, "Grok");
|
||||
assert(!dialog().querySelector("select")); assert(!dialog().textContent?.includes("Add accounts"));
|
||||
assert(!dialog().querySelector('[data-testid="subscription-privacy-notice"]')); assert(!maybeButton("Reconnect", dialog())); assert(!maybeButton("Sign in again", dialog()));
|
||||
assert.equal(footer().firstElementChild, button("Disconnect", dialog())); assert.match(button("Disconnect", dialog()).className, /text-red/);
|
||||
assert.equal(footer().lastElementChild?.lastElementChild, button("Close", dialog()));
|
||||
await click(toggle()); assert.deepEqual(f.calls.consent, [["xai", false]]); assert.equal(f.statuses.find((s) => s.providerId === "github-copilot")!.allowBackgroundUse, true);
|
||||
await click(button("Disconnect", dialog())); assert.equal(f.calls.disconnect.length, 0);
|
||||
assert.equal(dialog().querySelector("h2")?.textContent, "Grok"); assert(dialog().textContent?.includes("does not cancel your subscription or revoke access"));
|
||||
await click(button("Cancel", dialog())); assert(toggle()); assert(!maybeButton("Reconnect", dialog()));
|
||||
await click(button("Disconnect", dialog())); await click(button("Disconnect", dialog())); assert.deepEqual(f.calls.disconnect, ["xai"]); assert(!dialog());
|
||||
});
|
||||
test("authorization trouble offers Retry and staged Sign in again without removing the saved connection", async () => {
|
||||
const f = fixture(true); f.push("xai", { connected: false, allowBackgroundUse: false, error: "The connection could not be refreshed." });
|
||||
await mount(); await click(button("Manage…", row("xai"))); await click(button("Retry", dialog())); assert.deepEqual(f.calls.retry, ["xai"]);
|
||||
await click(button("Sign in again", dialog())); assert(dialog().querySelector('[data-testid="subscription-privacy-notice"]')); assert(!toggle());
|
||||
assert.deepEqual(f.calls.login, []); assert.deepEqual(f.calls.disconnect, []); assert.equal(f.statuses.find((s) => s.providerId === "xai")!.connectionId, "local-xai");
|
||||
await click(button("Continue", dialog())); assert.equal(toggle().getAttribute("aria-checked"), "false");
|
||||
await click(toggle()); await click(button("Connect", dialog())); assert.deepEqual(f.calls.login, [["xai", true]]); assert.deepEqual(f.calls.consent, []);
|
||||
await click(button("Cancel", dialog())); assert.equal(f.calls.cancel, 1); assert.deepEqual(f.calls.disconnect, []); assert.equal(f.statuses.find((s) => s.providerId === "xai")!.allowBackgroundUse, false);
|
||||
await act(async () => f.login.resolve());
|
||||
});
|
||||
test("a non-authority error on a still-working connection does not expose a healthy reauthorization action", async () => {
|
||||
const f = fixture(true); f.push("xai", { error: "The replacement sign-in did not complete." });
|
||||
await mount(); await click(button("Manage…", row("xai"))); assert(!maybeButton("Sign in again", dialog())); assert(!maybeButton("Retry", dialog()));
|
||||
});
|
||||
test("storage/desktop unavailability is not presented as an authorization repair action", async () => {
|
||||
const f = fixture(true); await mount(); await click(button("Manage…", row("xai")));
|
||||
await act(async () => f.push("xai", { available: false, connected: false, error: "The saved connection could not be read or written safely." }));
|
||||
assert(!maybeButton("Sign in again", dialog())); assert(!maybeButton("Retry", dialog())); assert(button("Close", dialog()));
|
||||
});
|
||||
test("required native prompts render and commit cannot be cancelled through button, Escape or backdrop", async () => {
|
||||
const f = fixture(); await mount(); await connectionStep("github-copilot"); await click(button("Connect", dialog()));
|
||||
await act(async () => f.push("github-copilot", { interaction: { attemptId: "attempt", prompt: { id: "enterprise", type: "text", message: "Enterprise domain (leave blank for github.com)", placeholder: "company.ghe.com" } } }));
|
||||
assert.equal(dialog().querySelector("input")?.getAttribute("placeholder"), "company.ghe.com"); assert(button("Continue", dialog()));
|
||||
assert(!dialog().querySelector('[data-testid="subscription-privacy-notice"]'));
|
||||
await act(async () => f.push("github-copilot", { committing: true, interaction: undefined }));
|
||||
assert(button("Cancel", dialog()).disabled); await click(button("Cancel", dialog())); assert.equal(f.calls.cancel, 0); assert(dialog());
|
||||
await act(async () => { const event = new window.Event("keydown", { bubbles: true }); Object.defineProperty(event, "key", { value: "Escape" }); document.dispatchEvent(event); });
|
||||
assert(dialog()); assert.equal(f.calls.cancel, 0);
|
||||
await act(async () => {
|
||||
const backdrop = document.querySelector<HTMLElement>('[data-testid="subscription-dialog-backdrop"]')!;
|
||||
for (const type of ["pointerdown", "pointerup", "click"]) { const event = new window.Event(type, { bubbles: true }); Object.defineProperties(event, { pointerId: { value: 1 }, isPrimary: { value: true }, button: { value: 0 } }); backdrop.dispatchEvent(event); }
|
||||
});
|
||||
assert(dialog()); assert.equal(f.calls.cancel, 0); await act(async () => f.login.resolve());
|
||||
});
|
||||
@@ -0,0 +1,107 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { after, before, test } from "node:test";
|
||||
import { normalizeContext, type Api, type AssistantMessage, type Model } from "@earendil-works/pi-ai";
|
||||
import { piModels, piEngineForSpec } from "../src/agent/models.ts";
|
||||
import { NATIVE_SUBSCRIPTIONS, type NativeSubscriptionSnapshot } from "../src/core/contracts/subscriptions.ts";
|
||||
import { refreshSubscriptionModels, nativeSubscriptionModels, subscriptionAbortController, subscriptionRequest } from "../src/agent/subscription-bridge.ts";
|
||||
import { subscriptionHistory } from "../src/agent/subscription-stream.ts";
|
||||
import { runSubagent } from "../src/agent/subagents/pi-run.ts";
|
||||
|
||||
const originalSend = Object.getOwnPropertyDescriptor(process, "send"), originalConnected = Object.getOwnPropertyDescriptor(process, "connected");
|
||||
const marker = process.env.LH_DESKTOP_SERVER, secret = process.env.LH_DESKTOP_API_SECRET;
|
||||
const xaiKey = process.env.XAI_API_KEY, copilotKey = process.env.COPILOT_GITHUB_TOKEN;
|
||||
let revision = 1, denied = false, sends = 0;
|
||||
const context = normalizeContext({ messages: [{ role: "user", content: "Offline fixture", timestamp: 0 }] });
|
||||
function snapshot(background = true): NativeSubscriptionSnapshot {
|
||||
return {
|
||||
statuses: NATIVE_SUBSCRIPTIONS.map((p) => ({ providerId: p.providerId, available: true, connected: true, pending: false, allowBackgroundUse: background, connectionId: `fixture-${p.providerId}`, epoch: 1, authorityRevision: 1, revision })),
|
||||
models: Object.fromEntries(NATIVE_SUBSCRIPTIONS.map((p) => [p.providerId, piModels().getModels(p.providerId)])),
|
||||
};
|
||||
}
|
||||
const emit = (value: unknown) => (process.emit as (event: string, value: unknown) => boolean)("message", value);
|
||||
before(async () => {
|
||||
process.env.LH_DESKTOP_SERVER = "1"; process.env.LH_DESKTOP_API_SECRET = "d".repeat(64);
|
||||
process.env.XAI_API_KEY = "fixture-must-never-be-used"; process.env.COPILOT_GITHUB_TOKEN = "fixture-api-route-only";
|
||||
Object.defineProperty(process, "connected", { configurable: true, value: true });
|
||||
Object.defineProperty(process, "send", { configurable: true, value: (message: any, callback?: () => void) => {
|
||||
sends++; queueMicrotask(() => emit({ channel: "lh:subscriptions", id: message.id, op: message.op, ...(denied && message.op === "lease" ? { ok: false, error: "fixture expired refresh" } : { ok: true, value: message.op === "snapshot" ? snapshot() : { scope: message.scope, auth: { apiKey: "fixture-oauth-only", ...(message.scope.providerId === "github-copilot" ? { baseUrl: "https://api.business.githubcopilot.com", headers: { "x-fixture-auth": "native-retained" } } : {}) } } }) }));
|
||||
callback?.(); return true;
|
||||
} });
|
||||
await refreshSubscriptionModels();
|
||||
});
|
||||
after(() => {
|
||||
if (originalSend) Object.defineProperty(process, "send", originalSend); else delete process.send;
|
||||
if (originalConnected) Object.defineProperty(process, "connected", originalConnected); else delete (process as { connected?: boolean }).connected;
|
||||
for (const [key, value] of [["LH_DESKTOP_SERVER", marker], ["LH_DESKTOP_API_SECRET", secret], ["XAI_API_KEY", xaiKey], ["COPILOT_GITHUB_TOKEN", copilotKey]]) { if (value === undefined) delete process.env[key!]; else process.env[key!] = value; }
|
||||
});
|
||||
test("logical subscription routes are separate catalog entries without rewriting native API models", () => {
|
||||
for (const p of NATIVE_SUBSCRIPTIONS) {
|
||||
assert(nativeSubscriptionModels().some((m) => m.provider === p.logicalId));
|
||||
assert(piModels().getModels(p.providerId).every((m) => m.provider === p.providerId));
|
||||
}
|
||||
});
|
||||
for (const p of NATIVE_SUBSCRIPTIONS) test(`${p.providerId}: every SDK transport keeps native identity, account auth and selected model before dispatch`, async () => {
|
||||
const apis = new Set<string>();
|
||||
for (const native of piModels().getModels(p.providerId)) {
|
||||
if (apis.has(native.api)) continue; apis.add(native.api);
|
||||
const engine = piEngineForSpec({ provider: p.logicalId, id: native.id }, false);
|
||||
let requests = 0, payloadObserved = false;
|
||||
const result = await engine.streamFn(engine.model, context, {
|
||||
onPayload: (raw, selected) => { payloadObserved = true; assert.equal(selected.provider, p.providerId); return { ...raw as object, model: "caller-cannot-replace-selected" }; },
|
||||
fetch: async (input, init) => {
|
||||
requests++; const request = new Request(input, init), body = await request.json();
|
||||
assert.equal(request.headers.get("authorization"), "Bearer fixture-oauth-only");
|
||||
assert.equal(body.model, native.id);
|
||||
if (p.providerId === "github-copilot") { assert.equal(new URL(request.url).origin, "https://api.business.githubcopilot.com"); assert.equal(request.headers.get("x-fixture-auth"), "native-retained"); }
|
||||
else assert.equal(new URL(request.url).origin, "https://api.x.ai");
|
||||
return new Response('{"error":{"message":"private-fixture-provider-detail"}}', { status: 401, headers: { "content-type": "application/json" } });
|
||||
},
|
||||
}).result();
|
||||
assert(payloadObserved); assert.equal(requests, 1); assert.equal(result.provider, p.logicalId); assert.equal(result.stopReason, "error");
|
||||
assert(!result.errorMessage?.includes("private-fixture-provider-detail")); assert(result.errorMessage?.includes("No API-key fallback"));
|
||||
}
|
||||
});
|
||||
test("failed OAuth lease never dispatches despite valid-looking ambient keys; forbidden auth overrides do not dispatch", async () => {
|
||||
let requests = 0; denied = true;
|
||||
try {
|
||||
for (const p of NATIVE_SUBSCRIPTIONS) {
|
||||
const model = piModels().getModels(p.providerId)[0]!, engine = piEngineForSpec({ provider: p.logicalId, id: model.id }, false);
|
||||
const fetch = async () => { requests++; throw new Error("must not dispatch"); };
|
||||
assert.equal((await engine.streamFn(engine.model, context, { fetch }).result()).stopReason, "error");
|
||||
assert.equal((await engine.streamFn(engine.model, context, { fetch, headers: { AUTHORIZATION: null } }).result()).stopReason, "error");
|
||||
}
|
||||
assert.equal(requests, 0);
|
||||
} finally { denied = false; }
|
||||
});
|
||||
test("a helper refused before dispatch reports no phantom usage or extraction step", async () => {
|
||||
denied = true; let usage = 0, steps = 0;
|
||||
try {
|
||||
const model = piModels().getModels("xai")[0]!, engine = piEngineForSpec({ provider: "xai-subscription", id: model.id });
|
||||
await assert.rejects(runSubagent([], { role: "researcher", engine, systemPrompt: "Offline fixture", prompt: "Do not dispatch", maxTurns: 1, onUsage: () => { usage++; }, onAssistantMessage: () => { steps++; } }));
|
||||
assert.equal(usage, 0); assert.equal(steps, 0);
|
||||
} finally { denied = false; }
|
||||
});
|
||||
test("history treatment distinguishes the same model through API and subscription, retaining only own reasoning signatures", () => {
|
||||
const native = piModels().getModels("xai")[0]!;
|
||||
const usage = { input: 1, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 1, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } };
|
||||
const message: AssistantMessage = { role: "assistant", provider: "xai", api: native.api, model: native.id, timestamp: 0, stopReason: "stop", usage, content: [{ type: "thinking", thinking: "", thinkingSignature: '{"encrypted_content":"private-api-signature"}', redacted: true }] };
|
||||
const own: AssistantMessage = { ...message, provider: "xai-subscription", content: [{ type: "thinking", thinking: "", thinkingSignature: '{"encrypted_content":"own-signature"}', redacted: true }] };
|
||||
const logical: Model<Api> = { ...native, provider: "xai-subscription" };
|
||||
const transformed = subscriptionHistory(normalizeContext({ messages: [message, own] }), logical, "xai");
|
||||
assert.deepEqual((transformed.messages[0] as AssistantMessage).content, []);
|
||||
assert.equal((transformed.messages[1] as AssistantMessage).content[0]!.type, "thinking");
|
||||
assert.equal(message.content.length, 1);
|
||||
});
|
||||
test("background invalidation is provider-specific and preserves foreground controllers; stale updates cannot reenable it", () => {
|
||||
const foreground = subscriptionAbortController("xai", false), background = subscriptionAbortController("xai", true), other = subscriptionAbortController("github-copilot", true);
|
||||
revision++; const next = snapshot(); next.statuses.find((s) => s.providerId === "xai")!.allowBackgroundUse = false;
|
||||
emit({ channel: "lh:subscriptions", op: "changed", snapshot: next });
|
||||
assert(!foreground.controller.signal.aborted); assert(background.controller.signal.aborted); assert(!other.controller.signal.aborted);
|
||||
const stale = snapshot(); stale.statuses.forEach((s) => { s.revision = 0; }); emit({ channel: "lh:subscriptions", op: "changed", snapshot: stale });
|
||||
foreground.release(); background.release(); other.release();
|
||||
});
|
||||
test("framework IPC alone cannot request subscription credentials or retain a usable catalog", async () => {
|
||||
const previous = sends; delete process.env.LH_DESKTOP_SERVER;
|
||||
await assert.rejects(subscriptionRequest("snapshot"), /desktop app/); assert.equal(sends, previous); assert.deepEqual(nativeSubscriptionModels(), []);
|
||||
process.env.LH_DESKTOP_SERVER = "1";
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
import React from "react";
|
||||
(globalThis as Record<string, unknown>).React = React;
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { subscriptionLabel, subscriptionLabels, isSubscriptionModel, type TurnUsage } from "../src/core/index.ts";
|
||||
import { sessionCost, callCost } from "../src/server/pricing.ts";
|
||||
import { graphCostSummary } from "../src/core/cost-summary.ts";
|
||||
import { planCostLabel } from "../components/usage-format.ts";
|
||||
import { StatsBar } from "../components/stats-bar.tsx";
|
||||
import { ModelMenuList } from "../components/settings/model-menu.tsx";
|
||||
import { toModelDto } from "../src/server/model-catalog.ts";
|
||||
import { piModels } from "../src/agent/models.ts";
|
||||
const step = { inputTokens: 1000, outputTokens: 100, cacheReadTokens: undefined, cacheWriteTokens: undefined, reasoningTokens: undefined };
|
||||
const turn = (model: string, id: string): TurnUsage => ({ id, messageId: id, at: "2026-10-24T00:00:00Z", model, steps: [step] });
|
||||
|
||||
test("only explicit subscription route IDs count as subscription use; names stay compact and unique", () => {
|
||||
assert.equal(subscriptionLabel("chatgpt:gpt-5"), "ChatGPT");
|
||||
assert.equal(subscriptionLabel("github-copilot-subscription:fixture"), "GitHub Copilot");
|
||||
assert.equal(subscriptionLabel("xai-subscription:grok"), "Grok");
|
||||
for (const model of ["openai:gpt-5", "xai:grok", "github-copilot:fixture", "meta:muse", "radius:any", "kimi-coding:any"]) assert(!isSubscriptionModel(model));
|
||||
assert.deepEqual(subscriptionLabels(["chatgpt:a", "xai-subscription:b", "chatgpt:c", "github-copilot-subscription:d"]), ["ChatGPT", "Grok", "GitHub Copilot"]);
|
||||
assert.equal(planCostLabel(0.12, ["Grok"], (n) => `$${n.toFixed(2)}`), "$0.12 + Grok");
|
||||
assert.equal(planCostLabel(0.67, ["ChatGPT"], (n) => `$${n.toFixed(2)}`), "$0.67 + ChatGPT");
|
||||
assert.equal(planCostLabel(null, ["ChatGPT", "GitHub Copilot", "Grok"]), "ChatGPT + GitHub Copilot + Grok");
|
||||
});
|
||||
test("mixed session pricing sums only API dollars and retains all subscription providers, not unpriced errors", () => {
|
||||
const turns = [turn("github-copilot-subscription:gpt-5", "copilot"), turn("xai-subscription:grok", "grok"), turn("chatgpt:gpt-5", "chatgpt"), turn("openai:gpt-5", "api")];
|
||||
const pricing = sessionCost({ turns }); assert.equal(pricing.subscription, 3); assert.equal(pricing.unpriced, 0); assert.equal(pricing.cost, callCost(step, "openai:gpt-5"));
|
||||
assert.deepEqual(pricing.subscriptionProviders, ["GitHub Copilot", "Grok", "ChatGPT"]);
|
||||
const html = renderToStaticMarkup(<StatsBar turns={turns} pricing={pricing} />);
|
||||
assert.match(html, /\$[\d.]+ \+ GitHub Copilot \+ Grok \+ ChatGPT/); assert(!html.includes(" API +")); assert(!html.includes("ChatGPT plan"));
|
||||
});
|
||||
test("graph stage/recent aggregates retain distinct subscription providers without inventing dollars", () => {
|
||||
const summary = graphCostSummary({ chats: [{ title: "Fixture", extractions: [{ startedAt: 1, at: "2026-10-24T00:00:00Z", durationMs: 1, stages: { extractor: { model: "github-copilot-subscription:gpt-5", steps: [step] }, auditor: { model: "xai-subscription:grok", steps: [step] } } }] }], sources: [] }, callCost);
|
||||
assert.equal(summary.totalDollars, 0); assert.equal(summary.unpricedRuns, 0); assert.equal(summary.runs[0]!.dollars, null);
|
||||
assert.deepEqual(summary.subscriptionProviders, ["GitHub Copilot", "Grok"]); assert.deepEqual(summary.runs[0]!.subscriptionProviders, ["GitHub Copilot", "Grok"]);
|
||||
});
|
||||
test("model menu distinguishes subscription connection and shows provider label rather than API prices", () => {
|
||||
const native = piModels().getModels("xai")[0]!;
|
||||
const html = renderToStaticMarkup(<ModelMenuList models={[toModelDto({ ...native, provider: "xai-subscription" })]} effectiveId={undefined} onPick={() => {}} />);
|
||||
assert(html.includes("Grok (subscription)")); assert(html.includes(">Grok</span>")); assert(!html.includes("USD per 1M")); assert(!html.includes("$0"));
|
||||
});
|
||||
Reference in New Issue
Block a user