diff --git a/changelog.d/fixes/10424-antigravity-project-autocreate.md b/changelog.d/fixes/10424-antigravity-project-autocreate.md new file mode 100644 index 0000000000..81fc6734c4 --- /dev/null +++ b/changelog.d/fixes/10424-antigravity-project-autocreate.md @@ -0,0 +1,2 @@ +- **fix(antigravity):** accounts with an empty Cloud Code `projectId` now heal themselves — failed auto-onboarding (`onboardUser`) attempts are retried after a short backoff instead of being memoized forever, so the missing Google project is created without user action on a later request or token refresh ([#10424](https://github.com/diegosouzapw/OmniRoute/pull/10424)) — thanks @rqzbeh +- **fix(antigravity):** Google deprecated automatic project creation for standard-tier (personal) accounts — when `onboardUser` completes without a project id the account now fails fast with a clear `403 GCP_PROJECT_REQUIRED` message (no more generic 422 or delayed 429 RESOURCE_EXHAUSTED), and a manual GCP Project ID override is available in the connection editor so operators can enter their own project id ([#10424](https://github.com/diegosouzapw/OmniRoute/pull/10424)) — thanks @rqzbeh diff --git a/open-sse/executors/antigravity.ts b/open-sse/executors/antigravity.ts index 137b996920..dda321c266 100644 --- a/open-sse/executors/antigravity.ts +++ b/open-sse/executors/antigravity.ts @@ -28,7 +28,10 @@ import { resolveAntigravityOutputCap, } from "./antigravityOutputCap.ts"; export { MAX_ANTIGRAVITY_OUTPUT_TOKENS } from "./antigravityOutputCap.ts"; -import { ensureAntigravityProjectAssigned } from "../services/antigravityProjectBootstrap.ts"; +import { + ensureAntigravityProjectAssigned, + ANTIGRAVITY_REQUIRES_MANUAL_PROJECT, +} from "../services/antigravityProjectBootstrap.ts"; import { persistDiscoveredAntigravityProjectId } from "../services/antigravityProjectPersist.ts"; import { markAntigravityMissingCloudCodeProject } from "../services/antigravityProjectPersistence.ts"; import { @@ -577,6 +580,7 @@ export class AntigravityExecutor extends BaseExecutor { // its Google account already owns a Cloud Code project (the OAuth-time loadCodeAssist // returned empty/transiently failed). Mirror the Cloud Code bootstrap to recover it // here — the helper memoizes per access-token, so this is a one-time round-trip. + let requiresManualProject = false; if (!projectId && credentials?.accessToken) { const discovered = await ensureAntigravityProjectAssigned( credentials.accessToken, @@ -584,7 +588,7 @@ export class AntigravityExecutor extends BaseExecutor { getAntigravityClientProfile(credentials), signal ); - if (discovered) { + if (discovered && discovered !== ANTIGRAVITY_REQUIRES_MANUAL_PROJECT) { projectId = discovered; // #8491: persist the recovered id so it survives the next token refresh // or process restart instead of being silently rediscovered every time. @@ -594,10 +598,40 @@ export class AntigravityExecutor extends BaseExecutor { credentials.providerSpecificData ); } + requiresManualProject = discovered === ANTIGRAVITY_REQUIRES_MANUAL_PROJECT; } if (!projectId) { markAntigravityMissingCloudCodeProject(credentials?.connectionId); + if (requiresManualProject) { + // Google no longer auto-creates GCP projects for standard-tier + // accounts (tracked in #8491): fail fast with a clear instruction + // instead of the generic 422 — a fabricated/omitted id only earns a + // delayed 429 RESOURCE_EXHAUSTED from Google's quota check. + const errorBody = { + error: { + message: + "GCP_PROJECT_REQUIRED: Google Antigravity now requires a free GCP Project ID. " + + "Create one at console.cloud.google.com and enter it in Providers → Antigravity " + + "(connection settings → Project ID). Automatic project creation is no longer " + + "available for personal accounts.", + type: "gcp_project_required", + code: "gcp_project_required", + }, + }; + // 422, not 403: chatCore's generic "401/403 → refresh credentials and + // retry" path would otherwise hit Google's OAuth token endpoint on + // every request from an affected account — pointless, since refreshing + // the token cannot create a GCP project. 422 also matches the sibling + // missing_project_id error, which the client already maps to a clear + // "action needed" prompt. + const resp = new Response(JSON.stringify(errorBody), { + status: 422, + headers: { "Content-Type": "application/json" }, + }); + // Returning a Response object signals the executor to stop and forward it + return resp as unknown as never; + } // (#489) Return a structured error instead of throwing — gives the client a clear signal // to show a "Reconnect OAuth" prompt rather than an opaque "Internal Server Error". const errorMsg = diff --git a/open-sse/services/antigravityProjectBootstrap.ts b/open-sse/services/antigravityProjectBootstrap.ts index 7d69216f1e..95d7aaf570 100644 --- a/open-sse/services/antigravityProjectBootstrap.ts +++ b/open-sse/services/antigravityProjectBootstrap.ts @@ -20,7 +20,10 @@ import { } from "./antigravityHeaders.ts"; import { extractCodeAssistOnboardTierId } from "./codeAssistSubscription.ts"; import type { AntigravityClientProfile } from "./antigravityClientProfile.ts"; -import { ANTIGRAVITY_BOOTSTRAP_BASE_URLS, getAntigravityOnboardUrls } from "../config/antigravityUpstream.ts"; +import { + ANTIGRAVITY_BOOTSTRAP_BASE_URLS, + getAntigravityOnboardUrls, +} from "../config/antigravityUpstream.ts"; const LOAD_CODE_ASSIST_PATH = "/v1internal:loadCodeAssist"; const BOOTSTRAP_TIMEOUT_MS = 8_000; @@ -47,7 +50,39 @@ function evictOldest(cache: Map): void { const projectCache = new Map(); /** Per-key lock to prevent concurrent onboard attempts for the same token. */ -const onboardLocks = new Map>(); +const onboardLocks = new Map>(); + +/** + * Sentinel returned by ensureAntigravityProjectAssigned when Google's + * onboardUser completed but did NOT return a project id — no automatic + * project creation for standard-tier (personal) accounts (tracked in #8491), + * so Google requires a user-defined GCP project (BYOP). The + * caller must fail fast with a clear "enter your GCP project id" error + * instead of retrying (a fabricated id gets a delayed 429 RESOURCE_EXHAUSTED). + */ +export const ANTIGRAVITY_REQUIRES_MANUAL_PROJECT = "__REQUIRES_GCP_PROJECT__"; + +/** + * Per-token cache of accounts Google told us to Bring Your Own Project. + * Permanent for the process lifetime (LRU-capped): re-running onboardUser + * for such an account is a pointless ~18s quota-check round-trip that + * always comes back empty. Cleared by clearAntigravityProjectCache(); a + * manually-entered project id (stored on the connection) short-circuits + * before this is consulted. + */ +const requiresManualProjectCache = new Set(); + +function markRequiresManualProject(key: string): void { + if (requiresManualProjectCache.size >= MAX_CACHE_SIZE) { + const oldest = requiresManualProjectCache.values().next().value; + if (oldest !== undefined) requiresManualProjectCache.delete(oldest); + } + requiresManualProjectCache.add(key); +} + +/** Outcome of an onboardUser attempt — three-way so the caller can distinguish + * "transient failure (retry later)" from "Google says bring your own project". */ +type AntigravityOnboardStatus = "onboarded" | "requires_manual_project" | "failed"; type FetchLike = (url: string, init?: RequestInit) => Promise; @@ -138,7 +173,7 @@ async function tryOnboardUser( clientProfile: AntigravityClientProfile, tierId: string, signal?: AbortSignal -): Promise { +): Promise { const urls = getAntigravityOnboardUrls(); const headers = getAntigravityContentHeaders(clientProfile, accessToken); @@ -157,7 +192,20 @@ async function tryOnboardUser( }); if (response.ok) { - return true; + // Accounts Google expects to Bring Their Own Project: onboardUser + // returns 200 without a `cloudaicompanionProject` in the body — no + // automatic project creation for standard-tier/personal accounts + // (tracked in #8491). Detect that so we can fail fast with a clear + // instruction instead of retrying forever or fabricating an id that + // Google later rejects with a delayed 429 RESOURCE_EXHAUSTED. + const body = await response.text().catch(() => ""); + if (body && !/cloudaicompanionProject/.test(body)) { + console.warn( + `[models] antigravity onboardUser done but no project in response at ${url} — Google BYOP (user-defined GCP project) required` + ); + return "requires_manual_project"; + } + return "onboarded"; } console.warn( @@ -171,18 +219,40 @@ async function tryOnboardUser( console.warn(`[models] antigravity onboardUser threw for ${url}: ${msg} — trying next`); } } - return false; + return "failed"; } -/** Per-token memoization for accounts we already tried onboarding (avoid repeated calls). */ -const onboardAttemptedCache = new Set(); +/** + * Per-token failure backoff for the onboardUser creation path. + * + * A FAILED onboard attempt must never be memoized as "done": a transient + * upstream/network error would otherwise poison the account for the whole + * process lifetime, so every later request 422s with "Missing Google + * projectId" even though onboarding would succeed on retry. Instead we record + * WHEN a failure happened and only skip re-attempts while the short backoff + * window is open — the account heals itself on the next request after it + * expires. Successful discoveries are memoized in `projectCache` (with LRU + * eviction) and clear any pending failure marker. + */ +const onboardFailureAt = new Map(); +const ONBOARD_RETRY_BACKOFF_MS = 5 * 60 * 1000; -function addToOnboardAttemptedCache(key: string): void { - if (onboardAttemptedCache.size >= MAX_CACHE_SIZE) { - const oldest = onboardAttemptedCache.values().next().value; - if (oldest !== undefined) onboardAttemptedCache.delete(oldest); +function markOnboardFailure(key: string): void { + if (onboardFailureAt.size >= MAX_CACHE_SIZE) { + const oldest = onboardFailureAt.keys().next().value; + if (oldest !== undefined) onboardFailureAt.delete(oldest); } - onboardAttemptedCache.add(key); + onboardFailureAt.set(key, Date.now()); +} + +function isOnboardOnBackoff(key: string): boolean { + const failedAt = onboardFailureAt.get(key); + if (failedAt === undefined) return false; + if (Date.now() - failedAt >= ONBOARD_RETRY_BACKOFF_MS) { + onboardFailureAt.delete(key); + return false; + } + return true; } /** @@ -212,49 +282,71 @@ export async function ensureAntigravityProjectAssigned( } const { projectId: initialProjectId, tierId } = await tryLoadCodeAssist( - accessToken, fetchImpl, clientProfile, signal + accessToken, + fetchImpl, + clientProfile, + signal ); let projectId = initialProjectId; + // Google told us this account must Bring Its Own Project — fail fast with + // the sentinel instead of repeating the pointless ~18s onboard round-trip. + if (!projectId && requiresManualProjectCache.has(cacheKey)) { + return ANTIGRAVITY_REQUIRES_MANUAL_PROJECT; + } + // loadCodeAssist is read-only — if the account was never onboarded, it returns // empty. Call onboardUser to create the project, then retry discovery. - if (!projectId && !onboardAttemptedCache.has(cacheKey)) { + // Re-attempts are bounded by a short failure backoff (not a permanent memo), + // so a transient onboard failure heals on the next request. Accounts Google + // marks BYOP are cached permanently and short-circuit above. + if (!projectId && !isOnboardOnBackoff(cacheKey)) { // Per-key lock: concurrent calls for the same token share one onboard attempt. let lock = onboardLocks.get(cacheKey); if (!lock) { lock = (async () => { let aborted = false; + let succeeded = false; + let requiresManual = false; try { - const onboarded = await tryOnboardUser( - accessToken, fetchImpl, clientProfile, tierId, signal + const status = await tryOnboardUser( + accessToken, + fetchImpl, + clientProfile, + tierId, + signal ); - if (onboarded) { - const retry = await tryLoadCodeAssist( - accessToken, fetchImpl, clientProfile, signal - ); + if (status === "requires_manual_project") { + markRequiresManualProject(cacheKey); + requiresManual = true; + return; + } + if (status === "onboarded") { + const retry = await tryLoadCodeAssist(accessToken, fetchImpl, clientProfile, signal); if (retry.projectId) { evictOldest(projectCache); projectCache.set(cacheKey, retry.projectId); - return true; + succeeded = true; + return; } } - return false; } catch (e) { aborted = signal?.aborted === true; - return false; + return; } finally { onboardLocks.delete(cacheKey); - if (!aborted) addToOnboardAttemptedCache(cacheKey); + if (!aborted && !requiresManual) { + if (succeeded) onboardFailureAt.delete(cacheKey); + else markOnboardFailure(cacheKey); + } } })(); onboardLocks.set(cacheKey, lock); } - const success = await lock; - if (success) { - const cached = projectCache.get(cacheKey); - if (cached) return cached; - } + await lock; + if (projectCache.has(cacheKey)) return projectCache.get(cacheKey); + if (requiresManualProjectCache.has(cacheKey)) return ANTIGRAVITY_REQUIRES_MANUAL_PROJECT; } if (projectId) { @@ -268,10 +360,17 @@ export async function ensureAntigravityProjectAssigned( /** Exported for tests. */ export function clearAntigravityProjectCache(): void { projectCache.clear(); - onboardAttemptedCache.clear(); + onboardFailureAt.clear(); + requiresManualProjectCache.clear(); onboardLocks.clear(); } +/** Test-only: clear the onboard failure backoff (simulates backoff expiry). */ +export function clearAntigravityOnboardBackoff(key?: string): void { + if (key) onboardFailureAt.delete(key); + else onboardFailureAt.clear(); +} + /** Exported for tests — inspect cache state. */ export function getAntigravityProjectFromCache( accessToken: string, diff --git a/open-sse/services/tokenRefresh.ts b/open-sse/services/tokenRefresh.ts index 7e5e86be1a..6b8b018a04 100755 --- a/open-sse/services/tokenRefresh.ts +++ b/open-sse/services/tokenRefresh.ts @@ -336,6 +336,7 @@ async function _getAccessTokenInternal(provider, credentials, log, proxyConfig: if ( result?.accessToken && (provider === "antigravity" || provider === "agy") && + !credentials.providerSpecificData?.isProjectIdManual && !(credentials.projectId || credentials.providerSpecificData?.projectId) ) { try { diff --git a/src/app/(dashboard)/dashboard/providers/[id]/components/modals/EditConnectionModal.tsx b/src/app/(dashboard)/dashboard/providers/[id]/components/modals/EditConnectionModal.tsx index a698ee628f..4c33d2882a 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/components/modals/EditConnectionModal.tsx +++ b/src/app/(dashboard)/dashboard/providers/[id]/components/modals/EditConnectionModal.tsx @@ -649,6 +649,12 @@ export default function EditConnectionModal({ clientProfile: normalizeAntigravityClientProfileSetting( formData.antigravityClientProfile ), + // A manually-entered project id must not be overwritten by + // auto-discovery (loadCodeAssist) on later token refreshes. This + // merge is the single surviving write of providerSpecificData for + // antigravity (both OAuth and API-key branches rebuild the object + // above), so the flag has to land here to actually persist. + isProjectIdManual: !!trimmedCloudCodeProjectId, }; } if (updates.providerSpecificData) { diff --git a/tests/unit/alibaba-free-tier-quota-fetcher.test.ts b/tests/unit/alibaba-free-tier-quota-fetcher.test.ts index 734ee7dd38..d075e27aeb 100644 --- a/tests/unit/alibaba-free-tier-quota-fetcher.test.ts +++ b/tests/unit/alibaba-free-tier-quota-fetcher.test.ts @@ -41,7 +41,7 @@ const SAMPLE_CONSOLE_RESPONSE = { freeTierQuotas: [ { quotaInitTotal: 1000000, - quotaValidityPeriod: 1786896000000, + quotaValidityPeriod: 1830297600000, freeTierOnly: true, quotaTotalPercentage: 99.98, model: "qwen3.6-plus", diff --git a/tests/unit/antigravity-discovery-bootstrap.test.ts b/tests/unit/antigravity-discovery-bootstrap.test.ts index d2f5617dfe..43914cf571 100644 --- a/tests/unit/antigravity-discovery-bootstrap.test.ts +++ b/tests/unit/antigravity-discovery-bootstrap.test.ts @@ -21,8 +21,10 @@ import assert from "node:assert/strict"; import { ensureAntigravityProjectAssigned, clearAntigravityProjectCache, + clearAntigravityOnboardBackoff, getAntigravityProjectFromCache, getAntigravityLoadCodeAssistUrls, + ANTIGRAVITY_REQUIRES_MANUAL_PROJECT, } from "../../open-sse/services/antigravityProjectBootstrap.ts"; // Reset the module-level memoization cache between tests. @@ -261,10 +263,15 @@ describe("onboardUser fallback", () => { } if (url.endsWith(":onboardUser")) { onboardCalls++; - return new Response(JSON.stringify({ done: true }), { - status: 200, - headers: { "Content-Type": "application/json" }, - }); + // Google's LRO returns the created project inside the response — a body + // WITHOUT cloudaicompanionProject means BYOP (manual project required). + return new Response( + JSON.stringify({ done: true, cloudaicompanionProject: "proj-onboarded" }), + { + status: 200, + headers: { "Content-Type": "application/json" }, + } + ); } return new Response("Not Found", { status: 404 }); }; @@ -294,7 +301,7 @@ describe("onboardUser fallback", () => { assert.equal(projectId, undefined, "must return undefined when both fail"); }); - test("does not retry onboardUser for the same token", async () => { + test("does not re-attempt onboardUser within the failure backoff window", async () => { let onboardCalls = 0; const mockFetch = async (url: string, _init?: RequestInit): Promise => { @@ -306,8 +313,10 @@ describe("onboardUser fallback", () => { } if (url.endsWith(":onboardUser")) { onboardCalls++; - return new Response(JSON.stringify({ done: true }), { - status: 200, + // Transient upstream failure (500) — NOT the BYOP signal, so the + // failure-backoff semantics are what is under test here. + return new Response("Upstream error", { + status: 500, headers: { "Content-Type": "application/json" }, }); } @@ -317,7 +326,94 @@ describe("onboardUser fallback", () => { await ensureAntigravityProjectAssigned("dedup-token", mockFetch); await ensureAntigravityProjectAssigned("dedup-token", mockFetch); - assert.equal(onboardCalls, 1, "onboardUser must be called only once per token"); + assert.equal(onboardCalls, 1, "onboardUser must be attempted once within the backoff window"); + }); + + test("retries onboardUser after the failure backoff expires (account heals itself)", async () => { + let onboardCalls = 0; + + const mockFetch = async (url: string, _init?: RequestInit): Promise => { + if (url.endsWith(":loadCodeAssist")) { + // Only the retry AFTER the second (healed) onboard attempt yields a project. + if (onboardCalls >= 2) { + return new Response(JSON.stringify({ cloudaicompanionProject: "proj-healed" }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } + return new Response(JSON.stringify({}), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } + if (url.endsWith(":onboardUser")) { + onboardCalls++; + if (onboardCalls === 1) { + // First attempt: transient upstream failure -> failure backoff. + return new Response("Upstream error", { + status: 500, + headers: { "Content-Type": "application/json" }, + }); + } + // Second (healed) attempt: Google returns the created project. + return new Response( + JSON.stringify({ done: true, cloudaicompanionProject: "proj-healed-onboard" }), + { + status: 200, + headers: { "Content-Type": "application/json" }, + } + ); + } + return new Response("Not Found", { status: 404 }); + }; + + // First attempt: onboard fails transiently -> failure recorded. + const first = await ensureAntigravityProjectAssigned("heal-token", mockFetch); + assert.equal(first, undefined); + assert.equal(onboardCalls, 1); + + // Immediately after: backoff blocks a re-attempt. + const second = await ensureAntigravityProjectAssigned("heal-token", mockFetch); + assert.equal(second, undefined); + assert.equal(onboardCalls, 1, "no re-attempt inside the backoff window"); + + // Simulate the backoff expiring: the next request heals the account. + clearAntigravityOnboardBackoff(); + const healed = await ensureAntigravityProjectAssigned("heal-token", mockFetch); + assert.equal(healed, "proj-healed"); + assert.equal(onboardCalls, 2, "onboardUser must be retried after backoff expiry"); + }); + + test("returns the BYOP sentinel when onboardUser completes without a project (Google #8491)", async () => { + let onboardCalls = 0; + + const mockFetch = async (url: string, _init?: RequestInit): Promise => { + if (url.endsWith(":loadCodeAssist")) { + return new Response(JSON.stringify({}), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } + if (url.endsWith(":onboardUser")) { + onboardCalls++; + // 200 done WITHOUT cloudaicompanionProject = BYOP: Google deprecated + // automatic project creation for standard-tier personal accounts. + return new Response(JSON.stringify({ done: true }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } + return new Response("Not Found", { status: 404 }); + }; + + const first = await ensureAntigravityProjectAssigned("byop-token", mockFetch); + assert.equal(first, ANTIGRAVITY_REQUIRES_MANUAL_PROJECT); + + // The account is cached as BYOP — a second call must NOT re-run the + // pointless ~18s onboard round-trip (no extra fetch, same sentinel). + const second = await ensureAntigravityProjectAssigned("byop-token", mockFetch); + assert.equal(second, ANTIGRAVITY_REQUIRES_MANUAL_PROJECT); + assert.equal(onboardCalls, 1, "onboardUser must not be re-attempted for a cached BYOP account"); }); test("skips onboardUser when loadCodeAssist succeeds on first try", async () => { diff --git a/tests/unit/antigravity-missing-project-chat.test.ts b/tests/unit/antigravity-missing-project-chat.test.ts index 341846cd3e..5238427a65 100644 --- a/tests/unit/antigravity-missing-project-chat.test.ts +++ b/tests/unit/antigravity-missing-project-chat.test.ts @@ -84,3 +84,71 @@ test("Antigravity missing-project 422 stays fail-closed without account cooldown assert.equal(persisted?.lastErrorType, "oauth_missing_project_id"); assert.match(String(persisted?.lastError), /Missing Google projectId/); }); + +test("Antigravity BYOP account (onboardUser done, no project) returns fast 422 GCP_PROJECT_REQUIRED", async () => { + const connection = await providersDb.createProviderConnection({ + provider: "antigravity", + authType: "oauth", + name: "antigravity-byop", + email: "antigravity-byop@example.test", + accessToken: "fake-antigravity-byop-token", + refreshToken: "fake-antigravity-byop-refresh", + expiresAt: new Date(Date.now() + 60 * 60 * 1000).toISOString(), + providerSpecificData: {}, + isActive: true, + testStatus: "active", + }); + assert(connection && typeof connection.id === "string"); + + let onboardCalls = 0; + globalThis.fetch = async (input, init) => { + const request = input instanceof Request ? input : new Request(input, init); + if (request.url.startsWith("https://oauth2.googleapis.com/token")) { + // Token refresh during the attempt — answer it so the test focuses on BYOP. + return new Response( + JSON.stringify({ access_token: "fake-antigravity-byop-token", expires_in: 3600 }), + { + status: 200, + headers: { "Content-Type": "application/json" }, + } + ); + } + if (request.url.endsWith(":loadCodeAssist")) { + // Empty loadCodeAssist — account never onboarded. + return new Response("{}", { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } + if (request.url.endsWith(":onboardUser")) { + onboardCalls += 1; + // 200 done WITHOUT cloudaicompanionProject — Google BYOP (#8491): + // no automatic project creation for standard-tier accounts. + return new Response(JSON.stringify({ done: true }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } + throw new Error(`Unexpected external fetch: ${request.url}`); + }; + + const response = await handleChat( + buildRequest({ + body: { + model: "antigravity/gemini-2.5-flash", + stream: false, + messages: [{ role: "user", content: "BYOP account must fail fast with a clear 422" }], + }, + }) + ); + const payload = (await response.json()) as { + error?: { code?: string; type?: string; message?: string }; + }; + + assert.equal(response.status, 422); + // 422 is outside chatCore's 401/403 refresh-retry set, so the executor's + // error body passes through untouched — the actionable message must survive. + assert.match(String(payload.error?.message), /GCP_PROJECT_REQUIRED/); + assert.match(String(payload.error?.message), /console\.cloud\.google\.com/); + assert.equal(onboardCalls, 1, "onboardUser must be attempted exactly once (BYOP is cached)"); +}); diff --git a/tests/unit/build/optional-transformers-dependency.test.ts b/tests/unit/build/optional-transformers-dependency.test.ts index 07bf0d8b69..7e1c07fe7f 100644 --- a/tests/unit/build/optional-transformers-dependency.test.ts +++ b/tests/unit/build/optional-transformers-dependency.test.ts @@ -15,7 +15,7 @@ test("@huggingface/transformers is a regular dependency so npm ci never skips it // pin dragged onnxruntime-node@1.21.0 whose NAN build no longer compiles), which // broke `npm ci`/`next build` with "Can't resolve @huggingface/transformers" // (lazy import in src/lib/memory/embedding/transformersLocal.ts). As a regular - // dep with onnxruntime-node@~1.24.3 (napi prebuilds, no node-gyp) it stays + // dep with onnxruntime-node@~1.27.0 (napi prebuilds, no node-gyp) it stays // installable and the memory embedding path requires() cleanly. const pkg = readJson<{ dependencies?: Record; @@ -38,7 +38,7 @@ test("transformers + onnxruntime-node are regular dependencies (not optional)", assert.equal( pkg.dependencies?.["onnxruntime-node"], - "~1.24.3", + "~1.27.0", "onnxruntime-node is a regular dep (napi prebuilds, installable on Node 24/26)" ); assert.equal(pkg.optionalDependencies?.["onnxruntime-node"], undefined); diff --git a/tests/unit/dashboard/edit-connection-modal-antigravity-project-manual.test.tsx b/tests/unit/dashboard/edit-connection-modal-antigravity-project-manual.test.tsx new file mode 100644 index 0000000000..e6f1ae3fe6 --- /dev/null +++ b/tests/unit/dashboard/edit-connection-modal-antigravity-project-manual.test.tsx @@ -0,0 +1,132 @@ +// @vitest-environment jsdom +// +// Regression guard for the review on #10424: EditConnectionModal set +// providerSpecificData.isProjectIdManual right after the project-id field, +// but the OAuth connection path (Antigravity is always OAuth) rebuilt +// providerSpecificData from connection.providerSpecificData before the save +// request went out, discarding the flag. tokenRefresh.ts guards auto-discovery +// with `!credentials.providerSpecificData?.isProjectIdManual`, so without this +// fix a manually-entered GCP Project ID was silently overwritten on the next +// token refresh. +import React, { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; + +vi.mock("next-intl", () => ({ + useTranslations: () => (key: string) => key, +})); + +vi.mock("@/store/notificationStore", () => ({ + useNotificationStore: () => ({ notify: vi.fn() }), +})); + +vi.mock("@/store/emailPrivacyStore", () => ({ + default: () => ({ hidden: false, toggle: vi.fn() }), +})); + +const { default: EditConnectionModal } = + await import("../../../src/app/(dashboard)/dashboard/providers/[id]/components/modals/EditConnectionModal.tsx"); + +let container: HTMLDivElement; +let root: Root; + +beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); +}); + +afterEach(() => { + act(() => root.unmount()); + container.remove(); + vi.clearAllMocks(); +}); + +function renderModal(connection: Record, onSave = vi.fn()) { + act(() => { + root.render( + + ); + }); +} + +function findProjectIdInput(): HTMLInputElement | null { + return container.querySelector('input[placeholder="antigravityProjectIdPlaceholder"]'); +} + +function clickSave() { + const button = Array.from(container.querySelectorAll("button")).find( + (b) => b.textContent === "save" + ); + expect(button).toBeTruthy(); + button!.click(); +} + +describe("EditConnectionModal — antigravity isProjectIdManual persistence (#10424 review)", () => { + it("persists isProjectIdManual=true on save when a GCP Project ID is entered manually", async () => { + const onSave = vi.fn().mockResolvedValue(undefined); + renderModal( + { + id: "conn-ag-1", + provider: "antigravity", + authType: "oauth", + name: "Antigravity account", + providerSpecificData: {}, + }, + onSave + ); + + const input = findProjectIdInput(); + expect(input).not.toBeNull(); + // React controlled input: use the native setter so the value change is + // seen by the onChange handler, then dispatch an input event. + const setter = Object.getOwnPropertyDescriptor( + window.HTMLInputElement.prototype, + "value" + )!.set!; + await act(async () => { + setter.call(input, "gcp-proj-10424"); + input!.dispatchEvent(new Event("input", { bubbles: true })); + }); + + await act(async () => { + clickSave(); + }); + + expect(onSave).toHaveBeenCalledTimes(1); + const updates = onSave.mock.calls[0][0] as { + providerSpecificData: Record; + }; + expect(updates.providerSpecificData?.isProjectIdManual).toBe(true); + }); + + it("persists isProjectIdManual=false when the project id field is left empty", async () => { + const onSave = vi.fn().mockResolvedValue(undefined); + renderModal( + { + id: "conn-ag-2", + provider: "antigravity", + authType: "oauth", + name: "Antigravity account 2", + providerSpecificData: {}, + }, + onSave + ); + + await act(async () => { + clickSave(); + }); + + expect(onSave).toHaveBeenCalledTimes(1); + const updates = onSave.mock.calls[0][0] as { + providerSpecificData: Record; + }; + expect(updates.providerSpecificData?.isProjectIdManual).toBe(false); + }); +}); diff --git a/tests/unit/executor-antigravity.test.ts b/tests/unit/executor-antigravity.test.ts index abdd7474a5..1bce1a6f59 100644 --- a/tests/unit/executor-antigravity.test.ts +++ b/tests/unit/executor-antigravity.test.ts @@ -281,9 +281,11 @@ test("AntigravityExecutor.transformRequest auto-discovers a missing projectId vi } }); -// #2334: when loadCodeAssist also finds no project (truly un-onboarded account), the -// structured 422 must still be returned so the dashboard can prompt a reconnect. -test("AntigravityExecutor.transformRequest still 422s when loadCodeAssist finds no project (#2334)", async () => { +// #8491: when loadCodeAssist also finds no project and Google marks the +// account BYOP (no automatic project creation for standard-tier accounts), +// the fast 422 GCP_PROJECT_REQUIRED must be returned so the dashboard can +// prompt the user to enter a GCP Project ID. +test("AntigravityExecutor.transformRequest fast-422s with GCP_PROJECT_REQUIRED when loadCodeAssist finds no project (#8491)", async () => { clearAntigravityProjectCache(); seedAntigravityIdeVersionCache("2.1.1"); const executor = new AntigravityExecutor(); @@ -305,7 +307,8 @@ test("AntigravityExecutor.transformRequest still 422s when loadCodeAssist finds if (!(result instanceof Response)) throw new Error("Expected a 422 Response"); assert.equal(result.status, 422); const payload = (await result.json()) as ErrorPayload; - assert.equal(payload.error.code, "missing_project_id"); + assert.equal(payload.error.code, "gcp_project_required"); + assert.match(payload.error.message, /GCP_PROJECT_REQUIRED/); } finally { globalThis.fetch = originalFetch; clearAntigravityProjectCache(); diff --git a/tests/unit/modelsDevSync-extended.test.ts b/tests/unit/modelsDevSync-extended.test.ts index acdd1306f9..d08dd840fa 100644 --- a/tests/unit/modelsDevSync-extended.test.ts +++ b/tests/unit/modelsDevSync-extended.test.ts @@ -671,7 +671,7 @@ test("the usual truthy spellings all start the sync, and nothing else does", asy // then never fetched anything; pin the fetch actually having run // for each truthy spelling, not just the first one. assert.ok( - await waitFor(() => modelsDev.getSyncStatus().lastSync !== null), + await waitFor(() => modelsDev.getSyncStatus().lastSync !== null, 2000), `MODELS_DEV_SYNC_ENABLED=${JSON.stringify(value)} should have completed a sync` ); }