diff --git a/apps/controller/src/store/nexu-config-store.ts b/apps/controller/src/store/nexu-config-store.ts index 953ac9b7d..5be4413fe 100644 --- a/apps/controller/src/store/nexu-config-store.ts +++ b/apps/controller/src/store/nexu-config-store.ts @@ -121,6 +121,15 @@ const GIFTED_CREDIT_SOURCES = new Set([ "test", ]); +const GIFTED_CREDIT_SOURCE_HINTS = [ + "gift", + "bonus", + "trial", + "promo", + "campaign", + "grant", +]; + export type DesktopCloudStateChange = { hadCloudInventory: boolean; hasCloudInventory: boolean; @@ -573,6 +582,10 @@ function isActiveCreditGrant( return false; } + if (typeof grant.expiresAt !== "string" || grant.expiresAt.length === 0) { + return true; + } + const expiresAtTimestamp = Date.parse(grant.expiresAt); if (!Number.isFinite(expiresAtTimestamp)) { return true; @@ -581,13 +594,32 @@ function isActiveCreditGrant( return expiresAtTimestamp > nowTimestamp; } +function isGiftedCreditGrant(grant: CreditRechargeRecord): boolean { + const source = grant.source.trim().toLowerCase(); + if (GIFTED_CREDIT_SOURCES.has(source)) { + return true; + } + + const searchableValues = [ + source, + typeof grant.description === "string" + ? grant.description.toLowerCase() + : "", + JSON.stringify(grant.metadata).toLowerCase(), + ]; + + return GIFTED_CREDIT_SOURCE_HINTS.some((hint) => + searchableValues.some((value) => value.includes(hint)), + ); +} + function deriveDesktopBalanceBreakdown(input: { totalBalance: number; grants: CreditRechargeRecord[]; }): DesktopBalanceBreakdown & { giftedBalanceRaw: number } { const nowTimestamp = Date.now(); const giftedBalanceRaw = input.grants.reduce((sum, grant) => { - if (!GIFTED_CREDIT_SOURCES.has(grant.source)) { + if (!isGiftedCreditGrant(grant)) { return sum; } diff --git a/apps/controller/tests/cloud-reward-service.test.ts b/apps/controller/tests/cloud-reward-service.test.ts index 380dfc62e..9c56b667d 100644 --- a/apps/controller/tests/cloud-reward-service.test.ts +++ b/apps/controller/tests/cloud-reward-service.test.ts @@ -546,6 +546,72 @@ describe("createCloudRewardService", () => { expect(result.reason).toBe("parse_error"); }); + it("accepts future credit source strings without failing the whole response", async () => { + vi.stubGlobal( + "fetch", + vi.fn( + async () => + new Response( + JSON.stringify({ + ...mockCreditRecordsResponse, + grants: [ + { + ...mockCreditRecordsResponse.grants[0], + source: "campaign_gift", + }, + ], + }), + { + status: 200, + }, + ), + ), + ); + + const service = createCloudRewardService({ + cloudUrl: CLOUD_URL, + apiKey: API_KEY, + }); + const result = await service.getCreditRecords(); + + expect(result.ok).toBe(true); + if (!result.ok) throw new Error("unreachable"); + expect(result.data.grants[0]?.source).toBe("campaign_gift"); + }); + + it("accepts credit records with null expiresAt", async () => { + vi.stubGlobal( + "fetch", + vi.fn( + async () => + new Response( + JSON.stringify({ + ...mockCreditRecordsResponse, + grants: [ + { + ...mockCreditRecordsResponse.grants[0], + expiresAt: null, + }, + ], + }), + { + status: 200, + }, + ), + ), + ); + + const service = createCloudRewardService({ + cloudUrl: CLOUD_URL, + apiKey: API_KEY, + }); + const result = await service.getCreditRecords(); + + expect(result.ok).toBe(true); + if (!result.ok) throw new Error("unreachable"); + expect(result.data.grants[0]?.expiresAt).toBeNull(); + }); + it("uses the credit records endpoint", async () => { let capturedUrl = ""; diff --git a/apps/controller/tests/nexu-config-store.test.ts b/apps/controller/tests/nexu-config-store.test.ts index 2a7213140..f97e2c4a3 100644 --- a/apps/controller/tests/nexu-config-store.test.ts +++ b/apps/controller/tests/nexu-config-store.test.ts @@ -915,6 +915,191 @@ describe("NexuConfigStore", () => { } }); + it("classifies future gifted grant sources into gifted balance instead of plan balance", async () => { + await mkdir(path.join(rootDir, ".nexu"), { recursive: true }); + await writeFile( + path.join(rootDir, ".nexu", "config.json"), + JSON.stringify( + { + version: 1, + desktop: { + cloud: { + connected: true, + polling: false, + userName: "Cloud User", + userEmail: "user@nexu.io", + connectedAt: "2026-04-01T00:00:00.000Z", + linkUrl: "https://link.nexu.io", + apiKey: "valid-key", + models: [], + }, + }, + secrets: {}, + }, + null, + 2, + ), + "utf8", + ); + + const store = new NexuConfigStore(env); + + let fetchCalls = 0; + vi.stubGlobal( + "fetch", + vi.fn(async () => { + fetchCalls += 1; + if (fetchCalls === 1) { + return new Response( + JSON.stringify({ + tasks: [], + progress: { claimedCount: 0, totalCount: 0, earnedCredits: 0 }, + cloudBalance: { + totalBalance: 480, + totalRecharged: 480, + totalConsumed: 0, + syncedAt: "2026-04-01T00:00:00.000Z", + updatedAt: "2026-04-01T00:00:00.000Z", + }, + }), + { status: 200 }, + ); + } + + return new Response( + JSON.stringify({ + appUserId: "user-1", + grants: [ + { + id: "campaign-grant", + appUserId: "user-1", + amount: 480, + balance: 480, + source: "campaign_gift", + sourceId: null, + description: "trial gift", + expiresAt: "2099-04-01T00:00:00.000Z", + enabled: true, + idempotencyKey: "campaign-gift-1", + metadata: {}, + createdAt: "2026-04-01T00:00:00.000Z", + updatedAt: "2026-04-01T00:00:00.000Z", + }, + ], + usageSummary: { + totalEntries: 0, + totalDueCredits: 0, + totalChargedCredits: 0, + totalCostUsd: "0", + }, + }), + { status: 200 }, + ); + }), + ); + + try { + const status = await store.getDesktopRewardsStatus(); + expect(status.cloudBalance?.totalBalance).toBe(480); + expect(status.cloudBalance?.giftedBalance).toBe(480); + expect(status.cloudBalance?.planBalance).toBe(0); + } finally { + vi.unstubAllGlobals(); + } + }); + + it("treats gifted grants with null expiresAt as active balance", async () => { + await mkdir(path.join(rootDir, ".nexu"), { recursive: true }); + await writeFile( + path.join(rootDir, ".nexu", "config.json"), + JSON.stringify( + { + version: 1, + desktop: { + cloud: { + connected: true, + polling: false, + userName: "Cloud User", + userEmail: "user@nexu.io", + connectedAt: "2026-04-01T00:00:00.000Z", + linkUrl: "https://link.nexu.io", + apiKey: "valid-key", + models: [], + }, + }, + secrets: {}, + }, + null, + 2, + ), + "utf8", + ); + + const store = new NexuConfigStore(env); + + let fetchCalls = 0; + vi.stubGlobal( + "fetch", + vi.fn(async () => { + fetchCalls += 1; + if (fetchCalls === 1) { + return new Response( + JSON.stringify({ + tasks: [], + progress: { claimedCount: 0, totalCount: 0, earnedCredits: 0 }, + cloudBalance: { + totalBalance: 300, + totalRecharged: 300, + totalConsumed: 0, + syncedAt: "2026-04-01T00:00:00.000Z", + updatedAt: "2026-04-01T00:00:00.000Z", + }, + }), + { status: 200 }, + ); + } + + return new Response( + JSON.stringify({ + appUserId: "user-1", + grants: [ + { + id: "signup-grant", + appUserId: "user-1", + amount: 300, + balance: 300, + source: "signup_bonus", + sourceId: null, + description: null, + expiresAt: null, + enabled: true, + idempotencyKey: "signup-null-expiry", + metadata: {}, + createdAt: "2026-04-01T00:00:00.000Z", + updatedAt: "2026-04-01T00:00:00.000Z", + }, + ], + usageSummary: { + totalEntries: 0, + totalDueCredits: 0, + totalChargedCredits: 0, + totalCostUsd: "0", + }, + }), + { status: 200 }, + ); + }), + ); + + try { + const status = await store.getDesktopRewardsStatus(); + expect(status.cloudBalance?.giftedBalance).toBe(300); + expect(status.cloudBalance?.planBalance).toBe(0); + } finally { + vi.unstubAllGlobals(); + } + }); + it("falls back to plan-only balance when credit records cannot be loaded", async () => { await mkdir(path.join(rootDir, ".nexu"), { recursive: true }); await writeFile( diff --git a/apps/controller/tests/skillhub-custom-import.test.ts b/apps/controller/tests/skillhub-custom-import.test.ts index a6bad8572..5048f9f31 100644 --- a/apps/controller/tests/skillhub-custom-import.test.ts +++ b/apps/controller/tests/skillhub-custom-import.test.ts @@ -27,7 +27,7 @@ const { SkillDb } = await import("../src/services/skillhub/skill-db.js"); function stubExtractTo( slug: string, - skillsDir: string, + _skillsDir: string, opts: { withPackageJson?: boolean } = {}, ) { vi.mocked(extractZipMock).mockImplementationOnce( diff --git a/e2e/desktop/tests/packaged-e2e.mjs b/e2e/desktop/tests/packaged-e2e.mjs index 179e9ae18..93c7df09a 100644 --- a/e2e/desktop/tests/packaged-e2e.mjs +++ b/e2e/desktop/tests/packaged-e2e.mjs @@ -631,14 +631,27 @@ async function waitForDesktopReady() { return r.ok; }, "web ready"); - // Discover actual openclaw port from controller readiness payload or - // fall back to scanning common ports. The port may differ from 18789 - // if another service occupied it. - const ocPort = readyPayload?.openclawPort ?? 18789; - await waitFor(async () => { - const r = await fetch(`http://127.0.0.1:${ocPort}/health`); - return r.ok; - }, `openclaw health (port ${ocPort})`); + // Discover actual openclaw port from controller readiness payload, + // runtime-ports.json, or by scanning common ports. The port may differ + // from 18789 if another service occupied it at launch time. + const candidatePorts = [ + readyPayload?.openclawPort, + 18789, + 18790, + 18791, + ].filter(Boolean); + await waitFor( + async () => { + for (const p of candidatePorts) { + try { + const r = await fetch(`http://127.0.0.1:${p}/health`); + if (r.ok) return true; + } catch {} + } + return false; + }, + `openclaw health (ports ${[...new Set(candidatePorts)].join(",")})`, + ); } // --------------------------------------------------------------------------- diff --git a/packages/shared/src/schemas/credit.ts b/packages/shared/src/schemas/credit.ts index 3fb9f8d97..1d43715ef 100644 --- a/packages/shared/src/schemas/credit.ts +++ b/packages/shared/src/schemas/credit.ts @@ -1,6 +1,6 @@ import { z } from "zod"; -export const creditSourceTypeSchema = z.enum([ +export const knownCreditSourceTypeSchema = z.enum([ "signup_bonus", "daily_bonus", "github_star", @@ -8,7 +8,10 @@ export const creditSourceTypeSchema = z.enum([ "test", ]); +export const creditSourceTypeSchema = z.string().min(1); + export type CreditSourceType = z.infer; +export type KnownCreditSourceType = z.infer; export const creditUsageSummarySchema = z.object({ totalEntries: z.number().int().nonnegative(), @@ -45,7 +48,7 @@ export const creditRechargeRecordSchema = z.object({ source: creditSourceTypeSchema, sourceId: z.string().nullable(), description: z.string().nullable(), - expiresAt: z.string(), + expiresAt: z.string().nullable(), enabled: z.boolean(), idempotencyKey: z.string(), metadata: z.record(z.unknown()), diff --git a/tests/notify/developer-notify.test.ts b/tests/notify/developer-notify.test.ts index cb3b630c7..f74028d1e 100644 --- a/tests/notify/developer-notify.test.ts +++ b/tests/notify/developer-notify.test.ts @@ -36,7 +36,9 @@ describe("developer-notify", () => { prUrl: "https://github.com/nexu-io/nexu/pull/10", }); - expect(payload.card.header.title.content).toContain("又有新贡献者给 Nexu 提 PR"); + expect(payload.card.header.title.content).toContain( + "又有新贡献者给 Nexu 提 PR", + ); expect(payload.card.body.elements[0]).toMatchObject({ tag: "markdown", content: expect.stringContaining("**Title:** fix: resolve login crash"), @@ -72,7 +74,9 @@ describe("developer-notify", () => { ).toEqual(["Good First Issue", "贡献者指南", "查看全部 Issue"]); expect(payload.card.body.elements[2]).toMatchObject({ tag: "markdown", - content: expect.stringContaining("只需 3 步💥:❶ 选任务 ❷ 认领 ❸ 提交 PR"), + content: expect.stringContaining( + "只需 3 步💥:❶ 选任务 ❷ 认领 ❸ 提交 PR", + ), }); }); @@ -81,7 +85,9 @@ describe("developer-notify", () => { issueUrl: "https://github.com/nexu-io/nexu/issues/99", }); - expect(payload.card.header.title.content).toContain("刚新增 1 条 issue 等你来领取"); + expect(payload.card.header.title.content).toContain( + "刚新增 1 条 issue 等你来领取", + ); expect(payload.card.body.elements[1]).toMatchObject({ tag: "column_set" }); expect( payload.card.body.elements[1].columns.map(