From 5ae3053824aa3d644c0f07a70d740adc50da822e Mon Sep 17 00:00:00 2001 From: anthhub Date: Wed, 15 Apr 2026 14:19:14 +0800 Subject: [PATCH 1/4] fix: handle nullable gifted credit grants --- .../controller/src/store/nexu-config-store.ts | 34 +++- .../tests/cloud-reward-service.test.ts | 66 +++++++ .../tests/nexu-config-store.test.ts | 185 ++++++++++++++++++ packages/shared/src/schemas/credit.ts | 7 +- 4 files changed, 289 insertions(+), 3 deletions(-) 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/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()), From 33b9659962ac81bc50635c8a113050d02ad7823d Mon Sep 17 00:00:00 2001 From: anthhub Date: Thu, 16 Apr 2026 16:20:49 +0800 Subject: [PATCH 2/4] fix(e2e): scan candidate ports for openclaw health check The controller ready endpoint does not return openclawPort, so the E2E test always fell back to 18789. When that port is occupied on the CI runner, OpenClaw auto-assigns a different port and the health check times out. Scan 18789-18791 to match the bash harness behavior. --- e2e/desktop/tests/packaged-e2e.mjs | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/e2e/desktop/tests/packaged-e2e.mjs b/e2e/desktop/tests/packaged-e2e.mjs index 179e9ae18..45499196a 100644 --- a/e2e/desktop/tests/packaged-e2e.mjs +++ b/e2e/desktop/tests/packaged-e2e.mjs @@ -631,14 +631,19 @@ 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; + // 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 () => { - const r = await fetch(`http://127.0.0.1:${ocPort}/health`); - return r.ok; - }, `openclaw health (port ${ocPort})`); + 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(",")})`); } // --------------------------------------------------------------------------- From 6e602a266bac258adbfe0c47001ca29ca2f2dd69 Mon Sep 17 00:00:00 2001 From: anthhub Date: Thu, 16 Apr 2026 16:25:32 +0800 Subject: [PATCH 3/4] chore(e2e): fix biome formatting --- e2e/desktop/tests/packaged-e2e.mjs | 28 ++++++++++++++++++---------- 1 file changed, 18 insertions(+), 10 deletions(-) diff --git a/e2e/desktop/tests/packaged-e2e.mjs b/e2e/desktop/tests/packaged-e2e.mjs index 45499196a..93c7df09a 100644 --- a/e2e/desktop/tests/packaged-e2e.mjs +++ b/e2e/desktop/tests/packaged-e2e.mjs @@ -634,16 +634,24 @@ async function waitForDesktopReady() { // 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(",")})`); + 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(",")})`, + ); } // --------------------------------------------------------------------------- From c49549a7cb431611170b516fbe3721404c739c08 Mon Sep 17 00:00:00 2001 From: anthhub Date: Thu, 16 Apr 2026 16:32:02 +0800 Subject: [PATCH 4/4] chore: fix lint errors from main merge --- apps/controller/tests/skillhub-custom-import.test.ts | 2 +- tests/notify/developer-notify.test.ts | 12 +++++++++--- 2 files changed, 10 insertions(+), 4 deletions(-) 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/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(