diff --git a/.github/workflows/plugin-npm-release.yml b/.github/workflows/plugin-npm-release.yml index 8d12a5306195..c52a7ce70109 100644 --- a/.github/workflows/plugin-npm-release.yml +++ b/.github/workflows/plugin-npm-release.yml @@ -379,6 +379,7 @@ jobs: fi cat .local/plugin-npm-release-plan.json + jq -r '.warnings[]? | "- Warning: \(.)"' .local/plugin-npm-release-plan.json >> "$GITHUB_STEP_SUMMARY" candidate_count="$(jq -r '.candidates | length' .local/plugin-npm-release-plan.json)" selection_count="$(jq -r '.all | length' .local/plugin-npm-release-plan.json)" diff --git a/config/assertion-safety-baseline.txt b/config/assertion-safety-baseline.txt index c19014bd99d4..6da5c3a455fb 100644 --- a/config/assertion-safety-baseline.txt +++ b/config/assertion-safety-baseline.txt @@ -3148,7 +3148,7 @@ src/infra/outbound/deliver-queue-state.ts 1 src/infra/outbound/delivery-queue-media-spool.ts 2 src/infra/outbound/delivery-queue-media-staging.ts 2 src/infra/outbound/delivery-queue-preparation.ts 1 -src/infra/outbound/delivery-queue-storage.ts 8 +src/infra/outbound/delivery-queue-storage.ts 7 src/infra/outbound/envelope.ts 2 src/infra/outbound/format.ts 1 src/infra/outbound/message-account-selection.ts 2 diff --git a/docs/reference/RELEASING.md b/docs/reference/RELEASING.md index 30806b2eb451..0381de222c30 100644 --- a/docs/reference/RELEASING.md +++ b/docs/reference/RELEASING.md @@ -348,6 +348,7 @@ A legacy fallback correction tag may reuse base-package evidence only when the c - That workflow runs `OPENCLAW_LIVE_TEST=1 OPENCLAW_LIVE_CACHE_TEST=1 pnpm test:live:cache` using both `OPENAI_API_KEY` and `ANTHROPIC_API_KEY` workflow secrets. - npm release preflight no longer waits on the separate release checks lane. - Before tagging a release candidate locally, run `RELEASE_TAG=vYYYY.M.PATCH-beta.N pnpm release:fast-pretag-check`. The helper runs the fast release guardrails, plugin npm/ClawHub release checks, build, UI build, and `release:openclaw:npm:check` in the order that catches common approval-blocking mistakes before the GitHub publish workflow starts. +- Plugin `openclaw.release.requireLatestDependencies` declarations remain release metadata, but npm `latest` drift is advisory. Checks warn with the plugin, dependency, pinned version, and current latest version; a failed latest lookup also warns and does not establish that the pin is unusable. Full Release Validation's Codex lanes validate the `@openclaw/codex` harness pin. Keep that frozen, tested pin when upstream publishes a newer version. Missing or malformed required runtime dependency metadata, package/install failures, and failed required validation lanes still block release. - Run `RELEASE_TAG=vYYYY.M.PATCH node --import tsx scripts/openclaw-npm-release-check.ts` (or the matching prerelease/correction tag) before approval. - After npm publish, run `node --import tsx scripts/openclaw-npm-postpublish-verify.ts YYYY.M.PATCH` (or the matching beta/correction version) to verify the published registry install path in a fresh temp prefix. - After a beta publish, run `OPENCLAW_NPM_TELEGRAM_PACKAGE_SPEC=openclaw@YYYY.M.PATCH-beta.N OPENCLAW_NPM_TELEGRAM_CREDENTIAL_ROLE=maintainer pnpm test:docker:npm-telegram-live` with `OPENCLAW_QA_CONVEX_SITE_URL` and `OPENCLAW_QA_CONVEX_SECRET_MAINTAINER` set. This verifies installed-package onboarding, Telegram setup, and real Telegram E2E against the published npm package using the shared Test Server userbot pool. CI uses the `ci` role and `OPENCLAW_QA_CONVEX_SECRET_CI` instead. diff --git a/extensions/discord/src/monitor/native-command.guild-guards.test.ts b/extensions/discord/src/monitor/native-command.guild-guards.test.ts new file mode 100644 index 000000000000..08bca4c66ca8 --- /dev/null +++ b/extensions/discord/src/monitor/native-command.guild-guards.test.ts @@ -0,0 +1,176 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { ChannelType } from "discord-api-types/v10"; +import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; +import { + createTestRegistry, + setActivePluginRegistry, +} from "openclaw/plugin-sdk/plugin-test-runtime"; +import { + clearRuntimeConfigSnapshot, + setRuntimeConfigSnapshot, +} from "openclaw/plugin-sdk/runtime-config-snapshot"; +import { getSessionEntry, upsertSessionEntry } from "openclaw/plugin-sdk/session-store-runtime"; +import { afterEach, describe, expect, it } from "vitest"; +import { discordPlugin } from "../channel.js"; +import type { CommandInteraction } from "../internal/discord.js"; +import { createDiscordNativeCommand } from "./native-command.js"; +import { createMockCommandInteraction } from "./native-command.test-helpers.js"; +import { createNoopThreadBindingManager } from "./thread-bindings.manager.js"; + +const directories: string[] = []; +const userId = "100000000000000003"; +const channelId = "100000000000000001"; +const guildId = "100000000000000002"; +const otherChannelId = "100000000000000005"; +const sessionId = "existing-channel-session"; + +afterEach(async () => { + clearRuntimeConfigSnapshot(); + setActivePluginRegistry(createTestRegistry()); + await Promise.all( + directories.splice(0).map((dir) => fs.rm(dir, { recursive: true, force: true })), + ); +}); + +async function runNativeCommand(params: { + commandName: string; + guildChannels: Record; + configuredBinding?: boolean; + label: string; +}) { + const home = await fs.mkdtemp(path.join(os.tmpdir(), "discord-guild-guards-")); + directories.push(home); + const storePath = path.join(home, "sessions.json"); + const sessionKey = `agent:main:discord:channel:${channelId}`; + const scope = { agentId: "main", storePath, sessionKey }; + const cfg: OpenClawConfig = { + agents: { defaults: { workspace: home } }, + session: { store: storePath }, + commands: { allowFrom: { discord: [`user:${userId}`] } }, + ...(params.configuredBinding + ? { + bindings: [ + { + type: "acp", + agentId: "main", + match: { + channel: "discord", + accountId: "default", + peer: { kind: "channel", id: channelId }, + }, + acp: { backend: "acpx" }, + }, + ], + } + : {}), + channels: { + discord: { + commands: { native: true }, + guilds: { [guildId]: { channels: params.guildChannels } }, + }, + }, + } as OpenClawConfig; + setActivePluginRegistry( + createTestRegistry([{ pluginId: "discord", plugin: discordPlugin, source: "test" }]), + ); + await upsertSessionEntry({ + ...scope, + entry: { + sessionId, + lifecycleRevision: "before-reset", + updatedAt: Date.now(), + totalTokens: 100, + }, + }); + setRuntimeConfigSnapshot(cfg); + const interaction = createMockCommandInteraction({ + channelType: ChannelType.GuildText, + channelId, + guildId, + userId, + interactionId: params.label, + }); + const command = createDiscordNativeCommand({ + command: { name: params.commandName, description: "Guild guard probe.", acceptsArgs: true }, + cfg, + discordConfig: cfg.channels!.discord!, + accountId: "default", + sessionPrefix: "discord:slash", + ephemeralDefault: true, + threadBindings: createNoopThreadBindingManager("default"), + }); + await command.run(interaction as unknown as CommandInteraction); + return { + entry: getSessionEntry(scope), + replies: [...interaction.reply.mock.calls, ...interaction.followUp.mock.calls].map( + ([payload]) => payload?.content, + ), + }; +} + +describe("discord native command guild guards", () => { + it.each(["reset", "new"] as const)( + "refuses /%s in a disabled guild channel with no configured binding", + async (commandName) => { + const result = await runNativeCommand({ + commandName, + guildChannels: { [channelId]: { enabled: false } }, + label: `disabled-${commandName}`, + }); + expect(result.replies).toEqual(["This channel is disabled."]); + expect(result.entry?.lifecycleRevision).toBe("before-reset"); + }, + ); + + it.each(["reset", "new"] as const)( + "refuses /%s in a not-allowed guild channel with no configured binding", + async (commandName) => { + const result = await runNativeCommand({ + commandName, + guildChannels: { [otherChannelId]: { enabled: true } }, + label: `notallowed-${commandName}`, + }); + expect(result.replies).toEqual(["This channel is not allowed."]); + expect(result.entry?.lifecycleRevision).toBe("before-reset"); + }, + ); + + it("refuses /status in a disabled guild channel", async () => { + const result = await runNativeCommand({ + commandName: "status", + guildChannels: { [channelId]: { enabled: false } }, + label: "disabled-status", + }); + expect(result.replies).toEqual(["This channel is disabled."]); + }); + + it.each(["reset", "new"] as const)( + "still bypasses the guards for /%s when a configured binding owns the channel", + async (commandName) => { + const result = await runNativeCommand({ + commandName, + guildChannels: { [channelId]: { enabled: false } }, + configuredBinding: true, + label: `bound-${commandName}`, + }); + expect(result.replies).not.toContain("This channel is disabled."); + expect(result.replies).not.toContain("This channel is not allowed."); + expect(result.replies.length).toBeGreaterThan(0); + }, + ); + + it.each(["reset", "new"] as const)( + "passes guild guards for /%s in an enabled channel with no configured binding", + async (commandName) => { + const result = await runNativeCommand({ + commandName, + guildChannels: { [channelId]: { enabled: true } }, + label: `enabled-${commandName}`, + }); + expect(result.replies).not.toContain("This channel is disabled."); + expect(result.replies).not.toContain("This channel is not allowed."); + }, + ); +}); diff --git a/extensions/discord/src/monitor/native-command.ts b/extensions/discord/src/monitor/native-command.ts index 9024cf1a2fb6..1fa26a9e0b72 100644 --- a/extensions/discord/src/monitor/native-command.ts +++ b/extensions/discord/src/monitor/native-command.ts @@ -368,8 +368,7 @@ async function dispatchDiscordCommandInteraction(params: { return ( routeState.effectiveRoute.matchedBy === "binding.channel" || routeState.boundSessionKey != null || - routeState.configuredBinding != null || - routeState.configuredRoute != null + routeState.configuredBinding != null ); }; if (channelConfig?.enabled === false && !(await canBypassConfiguredAcpGuildGuards())) { diff --git a/scripts/lib/plugin-npm-release.ts b/scripts/lib/plugin-npm-release.ts index 74d0a3b4b7dc..0e024c572edf 100644 --- a/scripts/lib/plugin-npm-release.ts +++ b/scripts/lib/plugin-npm-release.ts @@ -30,6 +30,7 @@ type PluginReleasePlanItem = PublishablePluginPackage & { type PluginReleasePlan = { all: PluginReleasePlanItem[]; + warnings: string[]; candidates: PluginReleasePlanItem[]; skippedPublished: PluginReleasePlanItem[]; }; @@ -420,14 +421,13 @@ function resolveNpmLatestVersion(packageName: string): string { return parsed.trim(); } -export function collectPluginReleaseDependencyFreshnessErrors( +function collectPluginReleaseDependencyFreshnessMessages( plugins: readonly PublishablePluginPackage[], - resolveLatestVersion: NpmLatestVersionResolver = resolveNpmLatestVersion, + resolveLatestVersion: NpmLatestVersionResolver, + policy: "advisory" | "strict", ): string[] { - // Only plugin-owned opt-ins use this strict gate. It prevents release branches - // from silently carrying old executable pins while leaving normal dependencies alone. const latestVersions = new Map(); - const errors: string[] = []; + const messages: string[] = []; for (const plugin of plugins) { for (const dependency of plugin.requiredLatestDependencies ?? []) { @@ -437,21 +437,41 @@ export function collectPluginReleaseDependencyFreshnessErrors( latestVersion = resolveLatestVersion(dependency.packageName); latestVersions.set(dependency.packageName, latestVersion); } catch (error) { - errors.push( - `${plugin.packageName}@${plugin.version}: could not resolve npm latest for ${dependency.packageName}: ${error instanceof Error ? error.message : String(error)}`, + messages.push( + policy === "advisory" + ? `${plugin.packageName}@${plugin.version}: could not resolve npm latest for ${dependency.packageName} (pinned "${dependency.version}"); freshness is advisory: ${error instanceof Error ? error.message : String(error)}` + : `${plugin.packageName}@${plugin.version}: could not resolve npm latest for ${dependency.packageName}: ${error instanceof Error ? error.message : String(error)}`, ); continue; } } if (dependency.version !== latestVersion) { - errors.push( - `${plugin.packageName}@${plugin.version}: ${dependency.packageName} must match npm latest for release; found "${dependency.version}", latest is "${latestVersion}".`, + messages.push( + policy === "advisory" + ? `${plugin.packageName}@${plugin.version}: ${dependency.packageName} pinned "${dependency.version}", npm latest is "${latestVersion}". Freshness is advisory; retain the release-validated pin.` + : `${plugin.packageName}@${plugin.version}: ${dependency.packageName} must match npm latest for release; found "${dependency.version}", latest is "${latestVersion}".`, ); } } } - return errors; + return messages; +} + +export function collectPluginReleaseDependencyFreshnessWarnings( + plugins: readonly PublishablePluginPackage[], + resolveLatestVersion: NpmLatestVersionResolver = resolveNpmLatestVersion, +): string[] { + // Release validation owns pin compatibility. A moving npm dist-tag must not + // invalidate a frozen, tested candidate, including when the lookup is unavailable. + return collectPluginReleaseDependencyFreshnessMessages(plugins, resolveLatestVersion, "advisory"); +} + +function collectPluginReleaseDependencyFreshnessErrors( + plugins: readonly PublishablePluginPackage[], + resolveLatestVersion: NpmLatestVersionResolver = resolveNpmLatestVersion, +): string[] { + return collectPluginReleaseDependencyFreshnessMessages(plugins, resolveLatestVersion, "strict"); } export function assertPluginReleaseDependencyFreshness( @@ -470,6 +490,18 @@ export function assertPluginReleaseDependencyFreshness( ); } +export function warnPluginReleaseDependencyFreshness( + plugins: readonly PublishablePluginPackage[], + label: string, + resolveLatestVersion: NpmLatestVersionResolver = resolveNpmLatestVersion, +): string[] { + const warnings = collectPluginReleaseDependencyFreshnessWarnings(plugins, resolveLatestVersion); + for (const warning of warnings) { + console.warn(`${label}: warning: ${warning}`); + } + return warnings; +} + function isPluginVersionPublished(packageName: string, version: string): boolean { try { runNpmView([`${packageName}@${version}`, "version"]); @@ -527,7 +559,10 @@ export function collectPluginReleasePlan(params?: { if (explicitPublishSelection) { assertPluginReleaseVersionFloors(selectedPublishable, "Plugin NPM release plan"); } - assertPluginReleaseDependencyFreshness(selectedPublishable, "Plugin NPM release plan"); + const warnings = warnPluginReleaseDependencyFreshness( + selectedPublishable, + "Plugin NPM release plan", + ); const all = selectedPublishable.map((plugin) => Object.assign({}, plugin, { @@ -537,6 +572,7 @@ export function collectPluginReleasePlan(params?: { return { all, + warnings, candidates: all.filter((plugin) => !plugin.alreadyPublished), skippedPublished: all.filter((plugin) => plugin.alreadyPublished), }; diff --git a/src/infra/delivery-queue-sqlite-claim.ts b/src/infra/delivery-queue-sqlite-claim.ts index 58af1d5a692c..ea48baf141a7 100644 --- a/src/infra/delivery-queue-sqlite-claim.ts +++ b/src/infra/delivery-queue-sqlite-claim.ts @@ -38,6 +38,8 @@ export function transitionOwnedDeliveryQueueEntry( id: string; stateDir?: string; platformSendAttemptId: string | null; + /** A caller with no owner of its own treats an already-settled row as a no-op. */ + allowMissingEntry?: boolean; }, transition: (entry: DeliveryQueueEntryState) => void, ): boolean { @@ -49,7 +51,7 @@ export function transitionOwnedDeliveryQueueEntry( () => { const entry = loadDeliveryQueueEntry(params.queueName, params.id, params.stateDir); if (!entry) { - return false; + return params.allowMissingEntry === true; } if ( params.platformSendAttemptId === null diff --git a/src/infra/outbound/delivery-queue-storage.ts b/src/infra/outbound/delivery-queue-storage.ts index cd00735836a0..9e4dade7419b 100644 --- a/src/infra/outbound/delivery-queue-storage.ts +++ b/src/infra/outbound/delivery-queue-storage.ts @@ -298,23 +298,23 @@ export async function ackDelivery( deleteDeliveryQueueEntry(OUTBOUND_DELIVERY_QUEUE_NAME, id, stateDir); } }; - if (options && "expectedPlatformSendAttemptId" in options) { - const settled = transitionOwnedDeliveryQueueEntry( - { - queueName: OUTBOUND_DELIVERY_QUEUE_NAME, - id, - stateDir, - platformSendAttemptId: options.expectedPlatformSendAttemptId ?? null, - }, - (entry) => settle(entry as QueuedDelivery), - ); - if (!settled) { - throw lostPlatformClaim(id); - } - } else { - settle( - loadDeliveryQueueEntry(OUTBOUND_DELIVERY_QUEUE_NAME, id, stateDir) as QueuedDelivery | null, - ); + // A claimless caller has no owner to assert, so an unclaimed row settles and an + // already-missing row is a no-op; either way it must never touch a live claim. + const ownsPlatformAttempt = options && "expectedPlatformSendAttemptId" in options; + const settled = transitionOwnedDeliveryQueueEntry( + { + queueName: OUTBOUND_DELIVERY_QUEUE_NAME, + id, + stateDir, + platformSendAttemptId: ownsPlatformAttempt + ? (options.expectedPlatformSendAttemptId ?? null) + : null, + allowMissingEntry: !ownsPlatformAttempt, + }, + (entry) => settle(entry as QueuedDelivery), + ); + if (!settled) { + throw lostPlatformClaim(id); } if (!options?.retainSpoolArtifacts) { await releaseSpoolArtifacts(spoolPaths, stateDir); diff --git a/src/infra/outbound/delivery-queue.storage.test.ts b/src/infra/outbound/delivery-queue.storage.test.ts index b4675e51e3f5..59ec662dbf78 100644 --- a/src/infra/outbound/delivery-queue.storage.test.ts +++ b/src/infra/outbound/delivery-queue.storage.test.ts @@ -420,6 +420,27 @@ describe("delivery-queue storage", () => { await expect(ackDelivery("nonexistent-id", tmpDir())).resolves.toBeUndefined(); }); + it("claimless ack rejects a live-claimed row instead of deleting it", async () => { + const stateDir = tmpDir(); + const id = await enqueueTextDelivery({ + channel: "directchat", + to: "+1", + payloads: [{ text: "claimless-ack-guard" }], + }); + const attemptId = await claimDeliveryPlatformSendAttempt(id, stateDir); + if (!attemptId) { + throw new Error("test invariant: the unclaimed row must accept a platform claim"); + } + + await expect(ackDelivery(id, stateDir)).rejects.toThrow( + `Delivery platform claim was lost: ${id}`, + ); + + const pending = await loadPendingDelivery(id, stateDir); + expect(pending).toMatchObject({ id, producerClaimId: attemptId }); + expect(readStatus(id)).toBe("pending"); + }); + it("removes acked entries from pending recovery", async () => { const id = await enqueueTextDelivery({ channel: "directchat", diff --git a/test/plugin-npm-release.test.ts b/test/plugin-npm-release.test.ts index 00ec918a9e85..f41a1869399e 100644 --- a/test/plugin-npm-release.test.ts +++ b/test/plugin-npm-release.test.ts @@ -7,7 +7,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { collectClawHubPublishablePluginPackages } from "../scripts/lib/plugin-clawhub-release.ts"; import { collectChangedExtensionIdsFromPaths, - collectPluginReleaseDependencyFreshnessErrors, + collectPluginReleaseDependencyFreshnessWarnings, collectPluginNpmGitRangeSelection, collectPluginReleasePlan, collectPluginReleaseVersionFloorErrors, @@ -20,6 +20,7 @@ import { parsePluginReleaseSelectionMode, resolveChangedPublishablePluginPackages, resolveSelectedPublishablePluginPackages, + warnPluginReleaseDependencyFreshness, type PublishablePluginPackage, } from "../scripts/lib/plugin-npm-release.ts"; import { writePublishablePluginFixture } from "./helpers/publishable-plugin-fixture.js"; @@ -47,6 +48,7 @@ const tempDirs: string[] = []; afterEach(() => { childProcessMock.execFileSyncOverride = undefined; + vi.restoreAllMocks(); cleanupTempDirs(tempDirs); }); @@ -416,7 +418,7 @@ describe("collectPluginReleaseVersionFloorErrors", () => { }); }); -describe("collectPluginReleaseDependencyFreshnessErrors", () => { +describe("collectPluginReleaseDependencyFreshnessWarnings", () => { const plugin: PublishablePluginPackage = { extensionId: "codex", packageDir: "extensions/codex", @@ -432,15 +434,15 @@ describe("collectPluginReleaseDependencyFreshnessErrors", () => { ], }; - it("rejects release dependencies older than the npm latest dist-tag", () => { - expect(collectPluginReleaseDependencyFreshnessErrors([plugin], () => "0.142.5")).toEqual([ - '@openclaw/codex@2026.6.11: @openai/codex must match npm latest for release; found "0.139.0", latest is "0.142.5".', + it("reports release dependencies older than the npm latest dist-tag", () => { + expect(collectPluginReleaseDependencyFreshnessWarnings([plugin], () => "0.142.5")).toEqual([ + '@openclaw/codex@2026.6.11: @openai/codex pinned "0.139.0", npm latest is "0.142.5". Freshness is advisory; retain the release-validated pin.', ]); }); it("accepts release dependencies matching the npm latest dist-tag", () => { expect( - collectPluginReleaseDependencyFreshnessErrors( + collectPluginReleaseDependencyFreshnessWarnings( [ { ...plugin, @@ -457,17 +459,17 @@ describe("collectPluginReleaseDependencyFreshnessErrors", () => { ).toEqual([]); }); - it("fails closed when npm latest cannot be resolved", () => { + it("reports unavailable npm latest as advisory", () => { expect( - collectPluginReleaseDependencyFreshnessErrors([plugin], () => { + collectPluginReleaseDependencyFreshnessWarnings([plugin], () => { throw new Error("registry unavailable"); }), ).toEqual([ - "@openclaw/codex@2026.6.11: could not resolve npm latest for @openai/codex: registry unavailable", + '@openclaw/codex@2026.6.11: could not resolve npm latest for @openai/codex (pinned "0.139.0"); freshness is advisory: registry unavailable', ]); }); - it("fails closed when the npm latest lookup times out", () => { + it("reports npm latest lookup timeouts as advisory", () => { childProcessMock.execFileSyncOverride = (( command: string, args?: readonly string[], @@ -489,13 +491,54 @@ describe("collectPluginReleaseDependencyFreshnessErrors", () => { throw Object.assign(new Error("spawnSync npm ETIMEDOUT"), { code: "ETIMEDOUT" }); }) as unknown as ExecFileSync; - expect(collectPluginReleaseDependencyFreshnessErrors([plugin])).toEqual([ - "@openclaw/codex@2026.6.11: could not resolve npm latest for @openai/codex: npm view timed out after 60000ms.", + expect(collectPluginReleaseDependencyFreshnessWarnings([plugin])).toEqual([ + '@openclaw/codex@2026.6.11: could not resolve npm latest for @openai/codex (pinned "0.139.0"); freshness is advisory: npm view timed out after 60000ms.', ]); }); + + it("logs advisory warnings without blocking the caller", () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + const warnings = warnPluginReleaseDependencyFreshness( + [plugin], + "release check", + () => "0.142.5", + ); + + expect(warnings).toHaveLength(1); + expect(warn).toHaveBeenCalledExactlyOnceWith(`release check: warning: ${warnings[0]}`); + }); }); describe("collectPluginReleasePlan", () => { + it("keeps a publish candidate when npm latest moved after validation", () => { + const repoDir = makeTempRepoRoot(tempDirs, "openclaw-plugin-npm-release-"); + writePublishablePluginFixture(repoDir, { + version: "2026.8.33", + publishTo: "npm", + dependency: { packageName: "demo-runtime", version: "1.2.3", requireLatest: true }, + }); + childProcessMock.execFileSyncOverride = ((command: string, args?: readonly string[]) => { + expect(command).toBe("npm"); + if (args?.[2] === "dist-tags.latest") { + return JSON.stringify("1.2.4"); + } + throw new Error("version not published"); + }) as unknown as ExecFileSync; + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + + const plan = collectPluginReleasePlan({ rootDir: repoDir }); + + expect(plan.candidates.map((candidate) => candidate.packageName)).toEqual([ + "@openclaw/demo-plugin", + ]); + expect(plan.warnings).toEqual([ + '@openclaw/demo-plugin@2026.8.33: demo-runtime pinned "1.2.3", npm latest is "1.2.4". Freshness is advisory; retain the release-validated pin.', + ]); + expect(warn).toHaveBeenCalledExactlyOnceWith( + `Plugin NPM release plan: warning: ${plan.warnings[0]}`, + ); + }); + it("fails closed when the published-version lookup times out", () => { const repoDir = makeTempRepoRoot(tempDirs, "openclaw-plugin-npm-release-"); writePublishablePluginFixture(repoDir, { diff --git a/test/scripts/direct-run-entrypoints.test.ts b/test/scripts/direct-run-entrypoints.test.ts index cb9aa987fb38..010ba3aceb99 100644 --- a/test/scripts/direct-run-entrypoints.test.ts +++ b/test/scripts/direct-run-entrypoints.test.ts @@ -243,7 +243,7 @@ process.exitCode = await new Promise((resolve, reject) => { ); it.each(["wrapper", "root package preloads"])( - "keeps %s and raw tsx children off disk caches without changing other cache settings", + "keeps %s and descendants off disk caches without changing other cache settings", async (entrypoint) => { await withShimFixture(TSX_SHIM_WRAPPERS[0], async (fixture) => { const { fixtureRoot, implementationPath, wrapperPath, runNode } = fixture; @@ -287,22 +287,36 @@ fs.readdirSync = function (directory, ...args) { key === "TMPDIR" || key === "TEMP" ? tempRoot : path.join(fixtureRoot, key), ]), ); - const childPath = path.join(fixtureRoot, "child.mts"); - const snapshotSource = ` + const childPath = path.join(fixtureRoot, "child.mjs"); + const environmentSnapshot = ` + env: Object.fromEntries(${JSON.stringify(Object.keys(preservedEnv))}.map(key => [key, process.env[key]])), + tsxDisableCache: process.env.TSX_DISABLE_CACHE, +`; + const transformedSnapshotSource = ` enum Transformed { Value = "transformed" } console.log(JSON.stringify({ transformed: Transformed.Value, args: process.argv.slice(2), cwd: process.cwd(), - env: Object.fromEntries(${JSON.stringify(Object.keys(preservedEnv))}.map(key => [key, process.env[key]])), +${environmentSnapshot} })); `; - writeFileSync(childPath, `${snapshotSource}\nprocess.exitCode = 17;\n`); + writeFileSync( + childPath, + `console.log(JSON.stringify({ + transformed: null, + args: process.argv.slice(2), + cwd: process.cwd(), +${environmentSnapshot} +})); +process.exitCode = 17; +`, + ); writeFileSync( implementationPath, - `${snapshotSource} + `${transformedSnapshotSource} import { spawnSync } from "node:child_process"; -const child = spawnSync(process.execPath, ["--import", "tsx", ${JSON.stringify(childPath)}, ...process.argv.slice(2)], { stdio: "inherit" }); +const child = spawnSync(process.execPath, [${JSON.stringify(childPath)}, ...process.argv.slice(2)], { stdio: "inherit" }); if (child.error) throw child.error; process.exitCode = child.status ?? 1; `, @@ -346,14 +360,22 @@ process.exitCode = child.status ?? 1; .trim() .split("\n") .map((line) => JSON.parse(line)), - ).toEqual( - Array.from({ length: 2 }, () => ({ + ).toEqual([ + { transformed: "transformed", args: ["argument with spaces", "--proof"], cwd: process.cwd(), env: preservedEnv, - })), - ); + tsxDisableCache: "1", + }, + { + transformed: null, + args: ["argument with spaces", "--proof"], + cwd: process.cwd(), + env: preservedEnv, + tsxDisableCache: "1", + }, + ]); if (entrypoint === "wrapper") { expect(result.stderr.trim().split("\n").at(-1)).toBe("[test] FAILED (exit 17)"); } diff --git a/ui/src/styles/corner-shape.browser.test.ts b/ui/src/styles/corner-shape.browser.test.ts index 6e79cd8aea34..8f37a6210ffd 100644 --- a/ui/src/styles/corner-shape.browser.test.ts +++ b/ui/src/styles/corner-shape.browser.test.ts @@ -209,6 +209,10 @@ const EXCLUDED_CASES: readonly CornerCase[] = [ const ALL_CASES = [...CORNER_CASES, ...ROUND_CASES, ...EXCLUDED_CASES]; +// CSSOM can serialize the same circular shape as round or superellipse(1). +// https://drafts.csswg.org/css-borders-4/#valdef-corner-shape-value-round +const CIRCULAR_SHAPE = expect.stringMatching(/^(?:round|superellipse\(1\))$/); + // The radius tokens themselves, read at :root exactly like // collectMcpAppStyleVariables() in mcp-app-theme.ts reads them for embedded // MCP apps. They must stay canonical/unscaled even under the superelliptical @@ -334,11 +338,11 @@ describeCornerShape("Control UI corner curvature", () => { ]), ...ROUND_CASES.map((corner) => [ corner.selector, - { radius: corner.superelliptical, shape: "round" }, + { radius: corner.superelliptical, shape: CIRCULAR_SHAPE }, ]), ...EXCLUDED_CASES.map((corner) => [ corner.selector, - { radius: corner.superelliptical, shape: "round" }, + { radius: corner.superelliptical, shape: CIRCULAR_SHAPE }, ]), ]), ); @@ -349,7 +353,10 @@ describeCornerShape("Control UI corner curvature", () => { expect(probe).toEqual( Object.fromEntries( - ALL_CASES.map((corner) => [corner.selector, { radius: corner.circular, shape: "round" }]), + ALL_CASES.map((corner) => [ + corner.selector, + { radius: corner.circular, shape: CIRCULAR_SHAPE }, + ]), ), ); });