diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 288a31a8e..c207751a6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -32,6 +32,10 @@ jobs: steps: - name: Checkout uses: actions/checkout@v4 + with: + fetch-depth: 0 + with: + fetch-depth: 0 - name: Setup pnpm uses: pnpm/action-setup@v4 @@ -106,11 +110,20 @@ jobs: NEXU_SKIP_RUNTIME_POSTINSTALL: "1" run: pnpm install --frozen-lockfile + - name: Select test plan + id: plan + run: node scripts/ci/test-plan.mjs + - name: Test with coverage + if: github.event_name != 'pull_request' run: pnpm test:coverage + - name: Run selected PR tests + if: github.event_name == 'pull_request' + run: ${{ steps.plan.outputs.test_command }} + - name: Upload unit coverage to Codecov - if: ${{ !cancelled() && hashFiles('coverage/lcov.info') != '' }} + if: ${{ github.event_name != 'pull_request' && !cancelled() && hashFiles('coverage/lcov.info') != '' }} uses: codecov/codecov-action@v6 with: use_oidc: true diff --git a/.github/workflows/desktop-ci-dev.yml b/.github/workflows/desktop-ci-dev.yml index c0b89fa77..7d14f1e6b 100644 --- a/.github/workflows/desktop-ci-dev.yml +++ b/.github/workflows/desktop-ci-dev.yml @@ -75,6 +75,8 @@ jobs: steps: - name: Checkout uses: actions/checkout@v4 + with: + fetch-depth: 0 - name: Setup pnpm uses: pnpm/action-setup@v4 @@ -112,6 +114,10 @@ jobs: - name: Install dependencies run: pnpm install --frozen-lockfile + - name: Select test plan + id: plan + run: node scripts/ci/test-plan.mjs + - name: Install tmux if: matrix.os == 'macos' shell: bash @@ -131,7 +137,7 @@ jobs: run: pnpm build - name: Run unit tests (includes real launchd integration tests on macOS) - run: pnpm test + run: ${{ github.event_name == 'pull_request' && steps.plan.outputs.test_command || 'pnpm test:all' }} - name: Launchd lifecycle e2e test shell: bash diff --git a/.github/workflows/desktop-e2e.yml b/.github/workflows/desktop-e2e.yml index 96693260b..1b848cbab 100644 --- a/.github/workflows/desktop-e2e.yml +++ b/.github/workflows/desktop-e2e.yml @@ -63,6 +63,21 @@ concurrency: cancel-in-progress: true jobs: + plan: + if: github.event_name == 'pull_request' + runs-on: ubuntu-latest + outputs: + run_desktop_e2e: ${{ steps.plan.outputs.run_desktop_e2e }} + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Select desktop e2e plan + id: plan + run: node scripts/ci/test-plan.mjs + validate-inputs: if: github.event_name == 'workflow_dispatch' runs-on: ubuntu-latest @@ -79,12 +94,13 @@ jobs: # -------------------------------------------------------------------------- build: needs: [validate-inputs] - if: always() && (needs.validate-inputs.result == 'success' || needs.validate-inputs.result == 'skipped') && (github.event_name != 'workflow_dispatch' || github.event.inputs.source == 'build') && (github.event_name == 'workflow_dispatch' || github.event_name == 'pull_request' || github.event_name == 'push') + needs: [plan, validate-inputs] + if: always() && (needs.validate-inputs.result == 'success' || needs.validate-inputs.result == 'skipped') && (github.event_name != 'workflow_dispatch' || github.event.inputs.source == 'build') && (github.event_name == 'workflow_dispatch' || github.event_name == 'push' || (github.event_name == 'pull_request' && needs.plan.outputs.run_desktop_e2e == 'true')) runs-on: [self-hosted, macOS, ARM64] timeout-minutes: 30 env: E2E_MODE: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.mode || 'model' }} - E2E_COVERAGE: ${{ (github.event_name == 'workflow_dispatch' && github.event.inputs.coverage == 'true') || github.event_name == 'pull_request' || github.event_name == 'push' }} + E2E_COVERAGE: ${{ (github.event_name == 'workflow_dispatch' && github.event.inputs.coverage == 'true') || github.event_name == 'push' }} steps: - name: Checkout uses: actions/checkout@v4 @@ -146,15 +162,15 @@ jobs: # E2E test # -------------------------------------------------------------------------- e2e: - needs: [validate-inputs, build] - if: always() && (needs.validate-inputs.result == 'success' || needs.validate-inputs.result == 'skipped') && (needs.build.result == 'success' || needs.build.result == 'skipped') + needs: [plan, validate-inputs, build] + if: always() && (needs.validate-inputs.result == 'success' || needs.validate-inputs.result == 'skipped') && (needs.build.result == 'success' || needs.build.result == 'skipped') && (github.event_name != 'pull_request' || needs.plan.outputs.run_desktop_e2e == 'true') runs-on: [self-hosted, macOS, ARM64] timeout-minutes: 30 env: E2E_MODE: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.mode || 'model' }} E2E_SOURCE: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.source || (github.event_name == 'schedule' && 'download' || 'build') }} E2E_CHANNEL: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.channel || 'nightly' }} - E2E_COVERAGE: ${{ (github.event_name == 'workflow_dispatch' && github.event.inputs.coverage == 'true') || github.event_name == 'pull_request' || github.event_name == 'push' }} + E2E_COVERAGE: ${{ (github.event_name == 'workflow_dispatch' && github.event.inputs.coverage == 'true') || github.event_name == 'push' }} defaults: run: working-directory: e2e/desktop diff --git a/apps/controller/src/services/skillhub/skill-dir-watcher.ts b/apps/controller/src/services/skillhub/skill-dir-watcher.ts index a01d1afa1..7d73983ec 100644 --- a/apps/controller/src/services/skillhub/skill-dir-watcher.ts +++ b/apps/controller/src/services/skillhub/skill-dir-watcher.ts @@ -17,6 +17,7 @@ export class SkillDirWatcher { private readonly db: SkillDb; private readonly log: SkillDirWatcherLogFn; private readonly debounceMs: number; + private readonly pollIntervalMs: number; private readonly isSlugInFlight: (slug: string) => boolean; private readonly userSkillsDir: string | null; private readonly openclawStateDir: string | null; @@ -27,12 +28,14 @@ export class SkillDirWatcher { private workspaceWatcher: FSWatcher | null = null; private workspaceSkillWatchers = new Map(); private debounceTimer: ReturnType | null = null; + private pollTimer: ReturnType | null = null; constructor(opts: { skillsDir: string; skillDb: SkillDb; log?: SkillDirWatcherLogFn; debounceMs?: number; + pollIntervalMs?: number; /** Returns true if the slug is currently being installed by the queue. */ isSlugInFlight?: (slug: string) => boolean; /** User-level skills directory (~/.agents/skills/). */ @@ -48,6 +51,8 @@ export class SkillDirWatcher { this.db = opts.skillDb; this.log = opts.log ?? defaultLog; this.debounceMs = opts.debounceMs ?? 500; + this.pollIntervalMs = + opts.pollIntervalMs ?? Math.max(this.debounceMs * 4, 1000); this.isSlugInFlight = opts.isSlugInFlight ?? (() => false); this.userSkillsDir = opts.userSkillsDir ?? null; this.openclawStateDir = opts.openclawStateDir ?? null; @@ -233,7 +238,10 @@ export class SkillDirWatcher { return; } - this.sharedWatcher = watch(this.skillsDir, { recursive: true }, () => { + // Only the first-level slug directories matter here. Non-recursive watching + // is more reliable for newly created skill directories than relying on + // recursive child propagation across platforms. + this.sharedWatcher = watch(this.skillsDir, () => { this.scheduleSync(); }); @@ -247,7 +255,7 @@ export class SkillDirWatcher { this.log("info", `Watching skills directory: ${this.skillsDir}`); if (this.userSkillsDir && existsSync(this.userSkillsDir)) { - this.userWatcher = watch(this.userSkillsDir, { recursive: true }, () => { + this.userWatcher = watch(this.userSkillsDir, () => { this.scheduleSync(); }); @@ -290,6 +298,15 @@ export class SkillDirWatcher { `Watching workspace skill directories under: ${this.openclawStateDir}`, ); } + + // Some platforms occasionally miss new nested directory events from fs.watch. + // Keep a low-frequency reconciliation loop as a safety net so the ledger + // eventually reflects disk state even when no watcher callback fires. + this.pollTimer = setInterval(() => { + if (this.syncNow()) { + this.onChange(); + } + }, this.pollIntervalMs); } private shouldProcessWorkspaceEvent( @@ -309,6 +326,11 @@ export class SkillDirWatcher { this.debounceTimer = null; } + if (this.pollTimer !== null) { + clearInterval(this.pollTimer); + this.pollTimer = null; + } + if (this.sharedWatcher !== null) { this.sharedWatcher.close(); this.sharedWatcher = null; @@ -368,7 +390,7 @@ export class SkillDirWatcher { let watcher: FSWatcher; try { - watcher = watch(wsSkillsDir, { recursive: true }, () => { + watcher = watch(wsSkillsDir, () => { this.scheduleSync(); }); } catch (err) { diff --git a/package.json b/package.json index 008524f62..50d5680f0 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,10 @@ "lint": "biome check .", "lint:fix": "biome check --write --unsafe .", "format": "biome check --write .", - "test": "vitest run", + "test": "pnpm test:core", + "test:all": "vitest run", + "test:core": "vitest run --config vitest.core.config.ts", + "test:extended": "vitest run --config vitest.extended.config.ts", "test:coverage": "vitest run --coverage --coverage.reporter=lcov", "generate-types": "pnpm --filter @nexu/controller generate-openapi && pnpm --filter @nexu/web generate-sdk", "openclaw-runtime:install": "npm --prefix ./openclaw-runtime run install:cached", diff --git a/scripts/ci/test-plan.mjs b/scripts/ci/test-plan.mjs new file mode 100644 index 000000000..732609318 --- /dev/null +++ b/scripts/ci/test-plan.mjs @@ -0,0 +1,269 @@ +#!/usr/bin/env node + +import { execFileSync } from "node:child_process"; +import { appendFileSync } from "node:fs"; +import path from "node:path"; + +function git(args) { + return execFileSync("git", args, { encoding: "utf8" }).trim(); +} + +function hasRef(ref) { + try { + git(["rev-parse", "--verify", ref]); + return true; + } catch { + return false; + } +} + +function ensureBaseRefAvailable(baseRef) { + const remoteRef = `origin/${baseRef}`; + if (hasRef(remoteRef)) { + return remoteRef; + } + + git([ + "fetch", + "--no-tags", + "--depth=1", + "origin", + `refs/heads/${baseRef}:refs/remotes/${remoteRef}`, + ]); + + return remoteRef; +} + +function listChangedFiles() { + const eventName = process.env.GITHUB_EVENT_NAME; + if (eventName !== "pull_request") { + return []; + } + + const baseRef = process.env.GITHUB_BASE_REF || "main"; + const availableBaseRef = ensureBaseRefAvailable(baseRef); + const mergeBase = git(["merge-base", "HEAD", availableBaseRef]); + const diff = git(["diff", "--name-only", `${mergeBase}...HEAD`]); + return diff ? diff.split("\n").filter(Boolean) : []; +} + +const CORE_SMOKE_TESTS = [ + "tests/controller/runtime-stability-regressions.test.ts", + "tests/controller/nexu-credit-guard.test.ts", + "tests/controller/desktop-rewards-share-templates.test.ts", + "tests/controller/provider-oauth-routes.test.ts", + "tests/web/home.test.tsx", + "tests/web/budget-banner-dismissal.test.tsx", + "tests/web/workspace-layout-platform.test.tsx", + "tests/web/desktop-links.test.ts", + "tests/dev/stale-port-recovery.test.ts", + "tests/desktop/launchd-manager-ops.test.ts", + "tests/desktop/webview-preload-url.test.ts", + "tests/desktop/develop-set-balance-dialog.test.ts", + "tests/desktop/model-selection.test.ts", + "tests/desktop/runtime-config.test.ts", +]; + +const EXTENDED_GLOBS = [ + "tests/extended/", + "tests/desktop/launchd-", + "tests/desktop/update-server-integration.test.ts", + "tests/desktop/skill-dir-watcher", + "tests/desktop/daemon-supervisor", + "tests/desktop/lifecycle-teardown.test.ts", +]; + +const FULL_TEST_PATH_PREFIXES = [ + "packages/shared/", + "package.json", + "pnpm-lock.yaml", + "pnpm-workspace.yaml", + "tsconfig.base.json", + "vitest.config.ts", + "vitest.core.config.ts", + "vitest.extended.config.ts", + ".github/workflows/ci.yml", +]; + +const TARGET_MAP = [ + { + match: (file) => file.startsWith("apps/controller/static/runtime-plugins/"), + tests: ["tests/controller/nexu-credit-guard.test.ts"], + }, + { + match: (file) => + file === "apps/controller/src/lib/openclaw-config-compiler.ts" || + file === "apps/controller/src/services/openclaw-sync-service.ts", + tests: [ + "tests/controller/runtime-stability-regressions.test.ts", + "tests/desktop/openclaw-config-compiler.test.ts", + "tests/desktop/model-selection.test.ts", + ], + }, + { + match: (file) => + file === "apps/controller/src/services/skillhub/skill-dir-watcher.ts" || + file.startsWith("apps/controller/src/services/skillhub/"), + tests: [ + "tests/desktop/skill-dir-watcher.test.ts", + "tests/desktop/skill-dir-watcher-workspace.test.ts", + ], + }, + { + match: (file) => + file === "apps/web/src/layouts/workspace-layout.tsx" || + file === "apps/web/src/lib/desktop-platform.ts", + tests: ["tests/web/workspace-layout-platform.test.tsx"], + }, + { + match: (file) => file === "apps/web/src/lib/desktop-links.ts", + tests: ["tests/web/desktop-links.test.ts"], + }, + { + match: (file) => file.startsWith("scripts/dev/"), + tests: ["tests/dev/stale-port-recovery.test.ts"], + }, + { + match: (file) => file.startsWith("apps/desktop/main/services/launchd-"), + tests: [ + "tests/desktop/launchd-manager-ops.test.ts", + "tests/desktop/launchd-bootstrap-edge.test.ts", + "tests/desktop/launchd-bootstrap.test.ts", + ], + }, + { + match: (file) => file.startsWith("tests/"), + tests: [], + }, +]; + +const E2E_RELEVANT_PREFIXES = [ + "apps/desktop/", + "e2e/desktop/", + "openclaw-runtime/", + "openclaw-runtime-patches/", + "scripts/desktop-", + "scripts/dev-launchd.sh", + "package.json", + "pnpm-lock.yaml", + "apps/controller/src/runtime/", + "apps/controller/src/lib/openclaw-config-compiler.ts", + "apps/controller/src/services/openclaw-sync-service.ts", + "apps/controller/src/routes/desktop-", + "apps/web/src/layouts/workspace-layout.tsx", + "apps/web/src/lib/desktop-links.ts", + "apps/web/src/hooks/use-cloud-connect.ts", + "apps/web/src/pages/models.tsx", + "apps/web/src/pages/rewards.tsx", +]; + +function isExtendedTest(file) { + return ( + EXTENDED_GLOBS.some((pattern) => file.startsWith(pattern)) || + file.includes(".extended.test.") + ); +} + +function listAllTests() { + const files = git(["ls-files", "tests"]); + return files + .split("\n") + .filter(Boolean) + .filter((file) => /\.test\.(ts|tsx)$/.test(file)); +} + +function toSearchTokens(file) { + const parsed = path.parse(file); + const stem = parsed.name.replace(/\.test$/, ""); + const parts = file.split("/").filter(Boolean); + const basenameTokens = stem.split(/[-_.]/).filter((part) => part.length >= 4); + const dirTokens = parts + .slice(-3, -1) + .flatMap((part) => part.split(/[-_.]/)) + .filter((part) => part.length >= 4); + return [...new Set([stem, ...basenameTokens, ...dirTokens])]; +} + +function discoverMatchingTests(changedFiles, allTests) { + const selected = new Set(); + + for (const file of changedFiles) { + const tokens = toSearchTokens(file); + for (const testFile of allTests) { + if (isExtendedTest(testFile)) continue; + const normalized = testFile.toLowerCase(); + if (tokens.some((token) => normalized.includes(token.toLowerCase()))) { + selected.add(testFile); + } + } + } + + return selected; +} + +function selectTests(changedFiles) { + if ( + changedFiles.some((file) => + FULL_TEST_PATH_PREFIXES.some((prefix) => file.startsWith(prefix)), + ) + ) { + return { runFull: true, tests: [] }; + } + + const allTests = listAllTests(); + const selected = new Set(CORE_SMOKE_TESTS); + for (const discovered of discoverMatchingTests(changedFiles, allTests)) { + selected.add(discovered); + } + for (const file of changedFiles) { + if (file.startsWith("tests/") && !isExtendedTest(file)) { + selected.add(file); + } + for (const mapping of TARGET_MAP) { + if (mapping.match(file)) { + for (const testFile of mapping.tests) { + if (!isExtendedTest(testFile)) { + selected.add(testFile); + } + } + } + } + } + + return { runFull: false, tests: [...selected].sort() }; +} + +function shouldRunDesktopE2E(changedFiles) { + if (process.env.GITHUB_EVENT_NAME !== "pull_request") { + return true; + } + return changedFiles.some((file) => + E2E_RELEVANT_PREFIXES.some((prefix) => file.startsWith(prefix)), + ); +} + +function emit(name, value) { + const outputFile = process.env.GITHUB_OUTPUT; + const line = `${name}=${value}\n`; + if (outputFile) { + appendFileSync(outputFile, line); + } else { + process.stdout.write(line); + } +} + +const changedFiles = listChangedFiles(); +const { runFull, tests } = selectTests(changedFiles); +const eventName = process.env.GITHUB_EVENT_NAME; + +const testCommand = + eventName === "pull_request" + ? runFull + ? "pnpm test:all" + : `pnpm exec vitest run ${tests.map((file) => JSON.stringify(file)).join(" ")}` + : "pnpm test:all"; + +emit("run_full_tests", runFull ? "true" : "false"); +emit("test_command", testCommand); +emit("selected_tests", tests.join(",")); +emit("run_desktop_e2e", shouldRunDesktopE2E(changedFiles) ? "true" : "false"); diff --git a/tests/controller/nexu-credit-guard.test.ts b/tests/controller/nexu-credit-guard.test.ts new file mode 100644 index 000000000..3f3dd3b3d --- /dev/null +++ b/tests/controller/nexu-credit-guard.test.ts @@ -0,0 +1,95 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const statSync = vi.fn(() => ({ mtimeMs: 1 })); +const readFileSync = vi.fn(() => JSON.stringify({ locale: "en" })); + +vi.mock("node:fs", () => ({ + statSync, + readFileSync, +})); + +type Handler = (event: unknown, ctx: Record) => unknown; + +describe("nexu-credit-guard plugin", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.resetModules(); + statSync.mockReturnValue({ mtimeMs: 1 }); + readFileSync.mockReturnValue(JSON.stringify({ locale: "en" })); + }); + + it("replaces only same-channel error replies", async () => { + const handlers = new Map(); + const { default: plugin } = await import( + "../../apps/controller/static/runtime-plugins/nexu-credit-guard/index.js" + ); + + plugin.register({ + logger: { info: vi.fn() }, + on(event: string, handler: Handler) { + handlers.set(event, handler); + }, + pluginConfig: {}, + }); + + await handlers.get("llm_output")?.( + { + lastAssistant: + '{"error":{"code":"insufficient_credits","message":"insufficient credits"}}', + }, + { channelId: "channel-a" }, + ); + + const otherChannelResult = await handlers.get("message_sending")?.( + { content: "⚠️ something happened" }, + { channelId: "channel-b" }, + ); + const sameChannelResult = await handlers.get("message_sending")?.( + { content: "⚠️ upstream error" }, + { channelId: "channel-a" }, + ); + + expect(sameChannelResult).toEqual({ + content: + "⚠️ Insufficient credits. You can purchase a nexu plan to top up, or switch to using your own API key. If the issue persists, see [Contact us](https://nexu.app/contact).", + }); + expect(otherChannelResult).toBeUndefined(); + }); + + it("drops stale channel cache entries after the TTL", async () => { + vi.useFakeTimers(); + try { + const handlers = new Map(); + const { default: plugin } = await import( + "../../apps/controller/static/runtime-plugins/nexu-credit-guard/index.js" + ); + + plugin.register({ + logger: { info: vi.fn() }, + on(event: string, handler: Handler) { + handlers.set(event, handler); + }, + pluginConfig: {}, + }); + + await handlers.get("llm_output")?.( + { + lastAssistant: + '{"error":{"code":"invalid_api_key","message":"invalid"}}', + }, + { channelId: "channel-a" }, + ); + + vi.advanceTimersByTime(5_100); + + const result = await handlers.get("message_sending")?.( + { content: "⚠️ some unrelated error" }, + { channelId: "channel-a" }, + ); + + expect(result).toBeUndefined(); + } finally { + vi.useRealTimers(); + } + }); +}); diff --git a/tests/controller/runtime-stability-regressions.test.ts b/tests/controller/runtime-stability-regressions.test.ts new file mode 100644 index 000000000..037796839 --- /dev/null +++ b/tests/controller/runtime-stability-regressions.test.ts @@ -0,0 +1,278 @@ +import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; +import type { ControllerEnv } from "#controller/app/env"; +import { compileOpenClawConfig } from "#controller/lib/openclaw-config-compiler"; +import { CreditGuardStateWriter } from "#controller/runtime/credit-guard-state-writer"; +import { OpenClawAuthProfilesStore } from "#controller/runtime/openclaw-auth-profiles-store"; +import { OpenClawAuthProfilesWriter } from "#controller/runtime/openclaw-auth-profiles-writer"; +import { OpenClawConfigWriter } from "#controller/runtime/openclaw-config-writer"; +import { OpenClawRuntimeModelWriter } from "#controller/runtime/openclaw-runtime-model-writer"; +import { OpenClawRuntimePluginWriter } from "#controller/runtime/openclaw-runtime-plugin-writer"; +import { OpenClawWatchTrigger } from "#controller/runtime/openclaw-watch-trigger"; +import { WorkspaceTemplateWriter } from "#controller/runtime/workspace-template-writer"; +import { OpenClawGatewayService } from "#controller/services/openclaw-gateway-service"; +import { OpenClawSyncService } from "#controller/services/openclaw-sync-service"; +import { CompiledOpenClawStore } from "#controller/store/compiled-openclaw-store"; +import { NexuConfigStore } from "#controller/store/nexu-config-store"; +import type { NexuConfig } from "#controller/store/schemas"; + +function createEnv(rootDir = "/tmp/nexu-runtime-stability"): ControllerEnv { + return { + nodeEnv: "test", + port: 3010, + host: "127.0.0.1", + webUrl: "http://localhost:5173", + nexuCloudUrl: "https://nexu.io", + nexuLinkUrl: null, + nexuHomeDir: path.join(rootDir, ".nexu"), + nexuConfigPath: path.join(rootDir, ".nexu", "config.json"), + artifactsIndexPath: path.join(rootDir, ".nexu", "artifacts", "index.json"), + compiledOpenclawSnapshotPath: path.join( + rootDir, + ".nexu", + "compiled-openclaw.json", + ), + openclawStateDir: path.join(rootDir, ".openclaw"), + openclawConfigPath: path.join(rootDir, ".openclaw", "openclaw.json"), + openclawSkillsDir: path.join(rootDir, ".openclaw", "skills"), + userSkillsDir: path.join(rootDir, ".agents", "skills"), + openclawBuiltinExtensionsDir: null, + openclawExtensionsDir: path.join(rootDir, ".openclaw", "extensions"), + bundledRuntimePluginsDir: path.join(rootDir, "bundled-runtime-plugins"), + runtimePluginTemplatesDir: path.join(rootDir, "runtime-plugins"), + openclawCuratedSkillsDir: path.join(rootDir, ".openclaw", "bundled-skills"), + openclawRuntimeModelStatePath: path.join( + rootDir, + ".openclaw", + "nexu-runtime-model.json", + ), + creditGuardStatePath: path.join( + rootDir, + ".openclaw", + "nexu-credit-guard-state.json", + ), + skillhubCacheDir: path.join(rootDir, ".nexu", "skillhub-cache"), + skillDbPath: path.join(rootDir, ".nexu", "skill-ledger.json"), + analyticsStatePath: path.join(rootDir, ".nexu", "analytics-state.json"), + staticSkillsDir: undefined, + platformTemplatesDir: undefined, + openclawWorkspaceTemplatesDir: path.join( + rootDir, + ".openclaw", + "workspace-templates", + ), + openclawBin: "openclaw", + litellmBaseUrl: null, + litellmApiKey: null, + openclawGatewayPort: 18789, + openclawGatewayToken: undefined, + manageOpenclawProcess: false, + gatewayProbeEnabled: false, + runtimeSyncIntervalMs: 2000, + runtimeHealthIntervalMs: 5000, + defaultModelId: "anthropic/claude-sonnet-4", + posthogApiKey: undefined, + posthogHost: undefined, + }; +} + +function createConfig(): NexuConfig { + const now = new Date().toISOString(); + return { + $schema: "https://nexu.io/config.json", + schemaVersion: 1, + app: {}, + bots: [ + { + id: "bot-1", + name: "Assistant", + slug: "assistant", + poolId: null, + status: "active", + modelId: "anthropic/claude-sonnet-4", + systemPrompt: null, + createdAt: now, + updatedAt: now, + }, + ], + runtime: { + gateway: { port: 18789, bind: "loopback", authMode: "token" }, + defaultModelId: "anthropic/claude-sonnet-4", + }, + models: { mode: "merge", providers: {} }, + providers: [ + { + id: "provider-1", + providerId: "anthropic", + displayName: "Anthropic", + enabled: true, + baseUrl: null, + apiKey: "anthropic-key", + models: ["claude-sonnet-4"], + createdAt: now, + updatedAt: now, + }, + { + id: "provider-2", + providerId: "openai", + displayName: "OpenAI", + enabled: true, + baseUrl: null, + apiKey: "openai-key", + models: ["gpt-4o"], + createdAt: now, + updatedAt: now, + }, + ], + integrations: [], + channels: [], + templates: {}, + skills: { + version: 1, + defaults: { enabled: true, source: "inline" }, + items: {}, + }, + desktop: {}, + secrets: {}, + } as NexuConfig; +} + +describe("runtime stability regressions", () => { + it("keeps plugins.allow deterministic across channel reorderings", () => { + const now = new Date().toISOString(); + const channels = [ + { + id: "wecom-channel-1", + botId: "bot-1", + channelType: "wecom", + accountId: "default", + status: "connected", + teamName: null, + appId: "wecom-bot-123", + botUserId: null, + createdAt: now, + updatedAt: now, + }, + { + id: "dingtalk-channel-1", + botId: "bot-1", + channelType: "dingtalk", + accountId: "default", + status: "connected", + teamName: null, + appId: "ding-app-123", + botUserId: null, + createdAt: now, + updatedAt: now, + }, + { + id: "qq-channel-1", + botId: "bot-1", + channelType: "qqbot", + accountId: "default", + status: "connected", + teamName: null, + appId: "qq-app-123", + botUserId: null, + createdAt: now, + updatedAt: now, + }, + ] satisfies NexuConfig["channels"]; + + const secrets = { + "channel:wecom-channel-1:botId": "wecom-bot-123", + "channel:wecom-channel-1:secret": "wecom-secret", + "channel:dingtalk-channel-1:clientId": "ding-app-123", + "channel:dingtalk-channel-1:clientSecret": "ding-secret", + "channel:qq-channel-1:appId": "qq-app-123", + "channel:qq-channel-1:clientSecret": "qq-secret", + }; + + const first = compileOpenClawConfig( + { ...createConfig(), channels, secrets }, + createEnv(), + ); + const second = compileOpenClawConfig( + { ...createConfig(), channels: [...channels].reverse(), secrets }, + createEnv(), + ); + + expect(first.plugins?.allow).toEqual(second.plugins?.allow); + expect(first.plugins?.allow).toEqual([ + ...[...(first.plugins?.allow ?? [])].sort(), + ]); + expect(first.plugins?.allow).toEqual( + expect.arrayContaining([ + "dingtalk-connector", + "nexu-credit-guard", + "nexu-platform-bootstrap", + "nexu-runtime-model", + "openclaw-qqbot", + "wecom", + ]), + ); + }); + + it("preserves explicit BYOK model selections when the provider has no allowlist", async () => { + const rootDir = await mkdtemp( + path.join(tmpdir(), "nexu-runtime-stability-"), + ); + const env = createEnv(rootDir); + + try { + const config = createConfig(); + config.desktop = { + ...config.desktop, + selectedModelId: "anthropic/claude-opus-4-6", + }; + config.runtime.defaultModelId = "anthropic/claude-opus-4-6"; + config.bots[0] = { + ...config.bots[0], + modelId: "anthropic/claude-opus-4-6", + }; + config.providers = config.providers.map((provider) => + provider.providerId === "anthropic" + ? { ...provider, models: [] } + : provider, + ); + + await mkdir(path.dirname(env.nexuConfigPath), { recursive: true }); + await writeFile(env.nexuConfigPath, JSON.stringify(config, null, 2)); + + const configStore = new NexuConfigStore(env); + const compiledStore = new CompiledOpenClawStore(env); + const authProfilesStore = new OpenClawAuthProfilesStore(env); + const syncService = new OpenClawSyncService( + env, + configStore, + compiledStore, + new OpenClawConfigWriter(env), + new OpenClawAuthProfilesWriter(authProfilesStore), + authProfilesStore, + new OpenClawRuntimePluginWriter(env), + new OpenClawRuntimeModelWriter(env), + new CreditGuardStateWriter(env), + new WorkspaceTemplateWriter(env), + new OpenClawWatchTrigger(env), + new OpenClawGatewayService( + { + isConnected: () => false, + } as never, + {} as never, + ), + ); + + await syncService.syncAllImmediate(); + + const runtimeModel = JSON.parse( + await readFile(env.openclawRuntimeModelStatePath, "utf8"), + ) as { selectedModelRef: string }; + + expect(runtimeModel.selectedModelRef).toBe("anthropic/claude-opus-4-6"); + } finally { + await rm(rootDir, { recursive: true, force: true }); + } + }); +}); diff --git a/tests/desktop/launchd-manager-ops.test.ts b/tests/desktop/launchd-manager-ops.test.ts index 29345a9fd..711575dae 100644 --- a/tests/desktop/launchd-manager-ops.test.ts +++ b/tests/desktop/launchd-manager-ops.test.ts @@ -255,6 +255,54 @@ describe("LaunchdManager", () => { expect(bootstrapCalls).toHaveLength(0); }); + it("clears disabled override even when plist is unchanged", async () => { + mockExecFile.mockImplementation( + ( + _cmd: string, + args: string[], + callback: ( + error: Error | null, + result: { stdout: string; stderr: string }, + ) => void, + ) => { + if (args.includes("print-disabled")) { + callback(null, { + stdout: '"io.nexu.controller" => true', + stderr: "", + }); + return; + } + callback(null, { stdout: "", stderr: "" }); + }, + ); + + const fs = await import("node:fs/promises"); + (fs.readFile as ReturnType).mockResolvedValueOnce( + "test", + ); + + const { LaunchdManager } = await import( + "../../apps/desktop/main/services/launchd-manager" + ); + const mgr = new LaunchdManager({ plistDir: "/tmp/test" }); + + await mgr.installService("io.nexu.controller", "test"); + + const enableCalls = mockExecFile.mock.calls.filter((call: unknown[]) => + (call[1] as string[]).includes("enable"), + ); + const bootstrapCalls = mockExecFile.mock.calls.filter((call: unknown[]) => + (call[1] as string[]).includes("bootstrap"), + ); + + expect(enableCalls).toHaveLength(1); + expect(enableCalls[0]?.[1]).toEqual([ + "enable", + "gui/501/io.nexu.controller", + ]); + expect(bootstrapCalls).toHaveLength(0); + }); + it("re-bootstraps if already registered but plist content changed", async () => { mockExecFile.mockImplementation( ( diff --git a/tests/desktop/skill-dir-watcher.test.ts b/tests/desktop/skill-dir-watcher.test.ts index 7018e6193..3b4a5b811 100644 --- a/tests/desktop/skill-dir-watcher.test.ts +++ b/tests/desktop/skill-dir-watcher.test.ts @@ -161,6 +161,7 @@ describe("SkillDirWatcher", () => { skillsDir, skillDb: db, debounceMs: 50, + pollIntervalMs: 50, }); watcher.start(); @@ -182,6 +183,7 @@ describe("SkillDirWatcher", () => { skillsDir, skillDb: db, debounceMs: 50, + pollIntervalMs: 50, }); watcher.syncNow(); expect(db.isInstalled("doomed-skill", "managed")).toBe(true); diff --git a/tests/dev/stale-port-recovery.test.ts b/tests/dev/stale-port-recovery.test.ts new file mode 100644 index 000000000..d2b0b084f --- /dev/null +++ b/tests/dev/stale-port-recovery.test.ts @@ -0,0 +1,118 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const getListeningPortPid = vi.fn(); +const waitForListeningPortPid = vi.fn(); +const waitFor = vi.fn(async (attempt: () => Promise) => attempt()); +const terminateProcess = vi.fn(async () => {}); +const spawnHiddenProcess = vi.fn(async () => ({ + pid: 1234, + child: {}, + dispose: vi.fn(), +})); +const waitForProcessStart = vi.fn(async () => {}); +const ensureParentDirectory = vi.fn(async () => {}); +const ensureDirectory = vi.fn(async () => {}); +const writeDevLock = vi.fn(async () => {}); +const readDevLock = vi.fn(async () => { + const error = new Error("ENOENT") as Error & { code?: string }; + error.code = "ENOENT"; + throw error; +}); + +vi.mock("@nexu/dev-utils", () => ({ + createNodeOptions: () => "--conditions=development", + ensureDirectory, + ensureParentDirectory, + getListeningPortPid, + readDevLock, + removeDevLock: vi.fn(async () => {}), + repoRootPath: "/repo", + resolveTsxPaths: () => ({ cliPath: "/repo/node_modules/tsx/cli.mjs" }), + spawnHiddenProcess, + terminateProcess, + waitFor, + waitForListeningPortPid, + waitForProcessStart, + writeDevLock, +})); + +vi.mock("../../scripts/dev/src/shared/dev-runtime-config.js", () => ({ + createControllerInjectedEnv: () => ({}), + createDesktopInjectedEnv: () => ({}), + createWebInjectedEnv: () => ({}), + getScriptsDevRuntimeConfig: () => ({ + controllerPort: 50800, + webPort: 50810, + openclawPort: 18789, + openclawBaseUrl: "http://127.0.0.1:18789", + }), +})); + +vi.mock("../../scripts/dev/src/shared/logger.js", () => ({ + getScriptsDevLogger: () => ({ info: vi.fn(), warn: vi.fn(), error: vi.fn() }), +})); + +vi.mock("../../scripts/dev/src/shared/logs.js", () => ({ + readLogTailFromFile: vi.fn(), +})); + +vi.mock("../../scripts/dev/src/shared/paths.js", () => ({ + controllerDevLockPath: "/tmp/controller.pid", + controllerSupervisorPath: "/repo/scripts/dev/src/supervisors/controller.ts", + getControllerDevLogPath: () => "/tmp/controller.log", + getWebDevLogPath: () => "/tmp/web.log", + webDevLockPath: "/tmp/web.pid", + webSupervisorPath: "/repo/scripts/dev/src/supervisors/web.ts", +})); + +vi.mock("../../scripts/dev/src/shared/trace.js", () => ({ + createDevMarkerArgs: () => [], +})); + +describe("scripts/dev stale listener recovery", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ ok: true, status: 200 }), + ); + }); + + it("kills a stale controller listener before spawning the controller service", async () => { + getListeningPortPid + .mockResolvedValueOnce(9000) + .mockRejectedValueOnce(new Error("gone")); + waitForListeningPortPid + .mockResolvedValueOnce(18789) + .mockResolvedValueOnce(50800); + + const { startControllerDevProcess } = await import( + "../../scripts/dev/src/services/controller" + ); + + const snapshot = await startControllerDevProcess({ + sessionId: "controller-session", + }); + + expect(terminateProcess).toHaveBeenCalledWith(9000); + expect(spawnHiddenProcess).toHaveBeenCalledOnce(); + expect(snapshot.workerPid).toBe(50800); + }); + + it("kills a stale web listener before spawning the web service", async () => { + getListeningPortPid + .mockResolvedValueOnce(9100) + .mockRejectedValueOnce(new Error("gone")); + waitForListeningPortPid.mockResolvedValueOnce(50810); + + const { startWebDevProcess } = await import( + "../../scripts/dev/src/services/web" + ); + + const snapshot = await startWebDevProcess({ sessionId: "web-session" }); + + expect(terminateProcess).toHaveBeenCalledWith(9100); + expect(spawnHiddenProcess).toHaveBeenCalledOnce(); + expect(snapshot.listenerPid).toBe(50810); + }); +}); diff --git a/tests/web/desktop-links.test.ts b/tests/web/desktop-links.test.ts new file mode 100644 index 000000000..97348af15 --- /dev/null +++ b/tests/web/desktop-links.test.ts @@ -0,0 +1,57 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { + openExternalUrl, + openLocalFolderUrl, + pathToFileUrl, +} from "../../apps/web/src/lib/desktop-links"; + +describe("desktop links", () => { + beforeEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + vi.stubGlobal("window", { + open: vi.fn(), + }); + }); + + it("uses the desktop host bridge for external links when available", async () => { + const invoke = vi.fn().mockResolvedValue({ ok: true }); + vi.stubGlobal("window", { + open: vi.fn(), + nexuHost: { invoke }, + }); + + await openExternalUrl("https://nexu.app/contact"); + + expect(invoke).toHaveBeenCalledWith("shell:open-external", { + url: "https://nexu.app/contact", + }); + }); + + it("falls back to window.open for external links outside desktop", async () => { + const open = vi.fn(); + vi.stubGlobal("window", { open }); + + await openExternalUrl("https://nexu.app/contact"); + + expect(open).toHaveBeenCalledWith( + "https://nexu.app/contact", + "_blank", + "noopener,noreferrer", + ); + }); + + it("falls back to the host bridge when opening a local folder and the controller is unavailable", async () => { + const invoke = vi.fn().mockResolvedValue({ ok: true }); + vi.stubGlobal("window", { + open: vi.fn(), + nexuHost: { invoke }, + }); + vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new Error("offline"))); + + const url = pathToFileUrl("/tmp/nexu/session-folder"); + await openLocalFolderUrl(url); + + expect(invoke).toHaveBeenCalledWith("shell:open-external", { url }); + }); +}); diff --git a/tests/web/workspace-layout-platform.test.tsx b/tests/web/workspace-layout-platform.test.tsx new file mode 100644 index 000000000..c78d23fbd --- /dev/null +++ b/tests/web/workspace-layout-platform.test.tsx @@ -0,0 +1,124 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { renderToStaticMarkup } from "react-dom/server"; +import { MemoryRouter, Route, Routes } from "react-router-dom"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +let mockedDesktopPlatform: string | null = null; + +vi.mock("@/lib/api", () => ({})); +vi.mock("@/lib/tracking", () => ({ track: vi.fn() })); +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ t: (key: string) => key }), +})); +vi.mock("@/hooks/use-auto-update", () => ({ + useAutoUpdate: () => ({ + phase: "idle", + percent: 0, + version: null, + download: vi.fn(), + install: vi.fn(), + }), +})); +vi.mock("@/hooks/use-community-catalog", () => ({ + useCommunitySkills: () => ({ data: { installedSkills: [] } }), +})); +vi.mock("@/hooks/use-locale", () => ({ + useLocale: () => ({ locale: "en", setLocale: vi.fn() }), +})); +vi.mock("@/lib/auth-client", () => ({ + authClient: { + useSession: () => ({ + data: { user: { email: "alice@example.com", name: "Alice" } }, + }), + signOut: vi.fn(), + }, +})); +vi.mock("@/lib/desktop-platform", () => ({ + isWindowsDesktopPlatform: () => mockedDesktopPlatform === "win32", + isMacDesktopPlatform: () => mockedDesktopPlatform === "darwin", +})); +vi.mock("../../apps/web/lib/api/sdk.gen", () => ({ + getApiV1Sessions: vi.fn(async () => ({ data: { sessions: [] } })), + getApiV1Me: vi.fn(async () => ({ + data: { email: "alice@example.com", name: "Alice" }, + })), +})); + +import { WorkspaceLayout } from "../../apps/web/src/layouts/workspace-layout"; + +const storage = new Map(); + +function installBrowserStubs(userAgent = "Mozilla/5.0") { + Object.defineProperty(globalThis, "localStorage", { + configurable: true, + value: { + getItem: (key: string) => storage.get(key) ?? null, + setItem: (key: string, value: string) => { + storage.set(key, value); + }, + removeItem: (key: string) => { + storage.delete(key); + }, + }, + }); + + Object.defineProperty(globalThis, "navigator", { + configurable: true, + value: { userAgent }, + }); +} + +function renderWorkspaceLayout(): string { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + queryClient.setQueryData(["sidebar-sessions"], []); + queryClient.setQueryData(["me"], { + email: "alice@example.com", + name: "Alice", + }); + + return renderToStaticMarkup( + + + + }> + Home body} /> + + + + , + ); +} + +describe("WorkspaceLayout desktop platform variants", () => { + beforeEach(() => { + storage.clear(); + storage.set("nexu_setup_complete", "1"); + mockedDesktopPlatform = null; + installBrowserStubs(); + }); + + it("adds mac desktop header offsets to clear the traffic lights", () => { + mockedDesktopPlatform = "darwin"; + installBrowserStubs("Mozilla/5.0 Electron"); + + const markup = renderWorkspaceLayout(); + + expect(markup).toContain( + "flex items-center justify-between px-3 shrink-0 -mt-14 h-14 pl-[76px] pt-[10px] pr-3 pb-0", + ); + expect(markup).toContain('title="layout.collapseSidebar"'); + }); + + it("keeps the windows desktop header layout separate from the mac offset", () => { + mockedDesktopPlatform = "win32"; + installBrowserStubs("Mozilla/5.0 Electron"); + + const markup = renderWorkspaceLayout(); + + expect(markup).toContain("fixed px-2 z-50"); + expect(markup).not.toContain("top-[10px] left-[76px]"); + expect(markup).not.toContain("-mt-14 h-14 pl-[76px] pt-[10px] pr-3 pb-0"); + }); +}); diff --git a/vitest.base.ts b/vitest.base.ts new file mode 100644 index 000000000..84329432f --- /dev/null +++ b/vitest.base.ts @@ -0,0 +1,37 @@ +import path from "node:path"; +import react from "@vitejs/plugin-react"; + +export const vitestBaseConfig = { + plugins: [react()], + resolve: { + alias: { + "#web": path.resolve(import.meta.dirname, "apps/web/src"), + "#desktop": path.resolve(import.meta.dirname, "apps/desktop"), + "#controller": path.resolve(import.meta.dirname, "apps/controller/src"), + "@": path.resolve(import.meta.dirname, "apps/web/src"), + "@web-gen": path.resolve(import.meta.dirname, "apps/web/lib"), + react: path.resolve(import.meta.dirname, "apps/web/node_modules/react"), + "react/jsx-runtime": path.resolve( + import.meta.dirname, + "apps/web/node_modules/react/jsx-runtime.js", + ), + "react/jsx-dev-runtime": path.resolve( + import.meta.dirname, + "apps/web/node_modules/react/jsx-dev-runtime.js", + ), + "react-dom": path.resolve( + import.meta.dirname, + "apps/web/node_modules/react-dom", + ), + "react-router-dom": path.resolve( + import.meta.dirname, + "apps/web/node_modules/react-router-dom", + ), + "@tanstack/react-query": path.resolve( + import.meta.dirname, + "apps/web/node_modules/@tanstack/react-query", + ), + }, + dedupe: ["react", "react-dom"], + }, +}; diff --git a/vitest.config.ts b/vitest.config.ts index 83797d365..2124f6952 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -1,43 +1,11 @@ -import path from "node:path"; -import react from "@vitejs/plugin-react"; import { defineConfig } from "vitest/config"; +import { vitestBaseConfig } from "./vitest.base"; export default defineConfig({ - plugins: [react()], + ...vitestBaseConfig, test: { fileParallelism: false, include: ["tests/**/*.test.{ts,tsx}"], exclude: ["tests/api/**"], }, - resolve: { - alias: { - "#web": path.resolve(import.meta.dirname, "apps/web/src"), - "#desktop": path.resolve(import.meta.dirname, "apps/desktop"), - "#controller": path.resolve(import.meta.dirname, "apps/controller/src"), - "@": path.resolve(import.meta.dirname, "apps/web/src"), - "@web-gen": path.resolve(import.meta.dirname, "apps/web/lib"), - react: path.resolve(import.meta.dirname, "apps/web/node_modules/react"), - "react/jsx-runtime": path.resolve( - import.meta.dirname, - "apps/web/node_modules/react/jsx-runtime.js", - ), - "react/jsx-dev-runtime": path.resolve( - import.meta.dirname, - "apps/web/node_modules/react/jsx-dev-runtime.js", - ), - "react-dom": path.resolve( - import.meta.dirname, - "apps/web/node_modules/react-dom", - ), - "react-router-dom": path.resolve( - import.meta.dirname, - "apps/web/node_modules/react-router-dom", - ), - "@tanstack/react-query": path.resolve( - import.meta.dirname, - "apps/web/node_modules/@tanstack/react-query", - ), - }, - dedupe: ["react", "react-dom"], - }, }); diff --git a/vitest.core.config.ts b/vitest.core.config.ts new file mode 100644 index 000000000..86a733935 --- /dev/null +++ b/vitest.core.config.ts @@ -0,0 +1,22 @@ +import { defineConfig, mergeConfig } from "vitest/config"; +import { vitestBaseConfig } from "./vitest.base"; + +export default mergeConfig( + vitestBaseConfig, + defineConfig({ + test: { + fileParallelism: false, + include: ["tests/**/*.test.{ts,tsx}"], + exclude: [ + "tests/api/**", + "tests/desktop/launchd-*.test.ts", + "tests/desktop/update-server-integration.test.ts", + "tests/desktop/skill-dir-watcher*.test.ts", + "tests/desktop/daemon-supervisor*.test.ts", + "tests/desktop/lifecycle-teardown.test.ts", + "tests/extended/**/*.test.{ts,tsx}", + "tests/**/*.extended.test.{ts,tsx}", + ], + }, + }), +); diff --git a/vitest.extended.config.ts b/vitest.extended.config.ts new file mode 100644 index 000000000..f8a80054d --- /dev/null +++ b/vitest.extended.config.ts @@ -0,0 +1,20 @@ +import { defineConfig, mergeConfig } from "vitest/config"; +import { vitestBaseConfig } from "./vitest.base"; + +export default mergeConfig( + vitestBaseConfig, + defineConfig({ + test: { + fileParallelism: false, + include: [ + "tests/extended/**/*.test.{ts,tsx}", + "tests/**/*.extended.test.{ts,tsx}", + "tests/desktop/launchd-*.test.ts", + "tests/desktop/update-server-integration.test.ts", + "tests/desktop/skill-dir-watcher*.test.ts", + "tests/desktop/daemon-supervisor*.test.ts", + "tests/desktop/lifecycle-teardown.test.ts", + ], + }, + }), +);