mirror of
https://github.com/openclaw/openclaw.git
synced 2026-10-03 01:29:56 +00:00
refactor: remove narrowly used helper dependencies (#158573)
* refactor: remove narrowly used helper dependencies Replace markdown-it-task-lists and grammY runner sequencing with their local owners. Remove direct ms/pretty-ms usage and share the existing duration factors and formatting. Preserve rendering and parsing contracts, and keep overlapping Telegram lanes ordered when a predecessor fails. Remove obsolete mocks, declarations, dependency metadata, and build references; regenerate the lockfile and Control UI boot manifest. Deduplicate an inherited over-limit replay test without changing coverage. Closes #158499 Co-authored-by: steipete <58493+steipete@users.noreply.github.com> * refactor: remove narrowly used helper dependencies Worked on by: - @steipete Co-authored-by: steipete <58493+steipete@users.noreply.github.com> OpenClaw-Publication: f4ef665a-7e2b-47ce-a454-adc19e798bee * refactor: remove narrowly used helper dependencies Worked on by: - @steipete Co-authored-by: steipete <58493+steipete@users.noreply.github.com> OpenClaw-Publication: abec5219-5bd0-4480-8604-a57c1063f638 * fix(ci): preserve base-only dependencies in UI comparison Materialize removed dependency declarations from the archived base lockfile before overlaying the candidate toolchain. Keep both builds on the same Vite and compressor without restoring removed application dependencies. Extend the real build regression with a dependency absent from the candidate and verify the original comparator fails to resolve it. Co-authored-by: steipete <58493+steipete@users.noreply.github.com> * refactor: remove narrowly used helper dependencies Worked on by: - @steipete Co-authored-by: steipete <58493+steipete@users.noreply.github.com> OpenClaw-Publication: 2db2eea4-87fe-4071-ab37-28da02f1bd24 * refactor: remove narrowly used helper dependencies Worked on by: - @steipete Co-authored-by: steipete <58493+steipete@users.noreply.github.com> OpenClaw-Publication: 8c381a6d-38f2-417f-85ee-9353cb5694dc --------- Co-authored-by: steipete <58493+steipete@users.noreply.github.com>
This commit is contained in:
parent
bafccc6003
commit
1a5325f035
46 changed files with 770 additions and 280 deletions
|
|
@ -1592,7 +1592,6 @@ src/agents/exec-auto-reviewer.ts 1
|
|||
src/agents/exec-defaults.ts 2
|
||||
src/agents/execution-auth-binding.ts 1
|
||||
src/agents/failover-error.ts 3
|
||||
src/agents/failover/retry-evidence.ts 1
|
||||
src/agents/fallback-skip-cache.ts 1
|
||||
src/agents/fast-mode.ts 1
|
||||
src/agents/harness/builtin-openclaw.ts 2
|
||||
|
|
@ -2028,7 +2027,6 @@ src/cli/nodes-cli/rpc.ts 2
|
|||
src/cli/one-shot-exit.ts 2
|
||||
src/cli/output-file.runtime.ts 1
|
||||
src/cli/pairing-cli.ts 1
|
||||
src/cli/parse-duration.ts 1
|
||||
src/cli/plugins-authoring-command.ts 7
|
||||
src/cli/plugins-command-helpers.ts 1
|
||||
src/cli/plugins-update-command.ts 5
|
||||
|
|
|
|||
|
|
@ -506,7 +506,6 @@ const bundledPluginIgnoredRuntimeDependencies = [
|
|||
const rootBundledPluginRuntimeDependencies = [
|
||||
"@anthropic-ai/sdk",
|
||||
"@google/genai",
|
||||
"@grammyjs/runner",
|
||||
"@grammyjs/transformer-throttler",
|
||||
"@homebridge/ciao",
|
||||
"@mozilla/readability",
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@
|
|||
"description": "OpenClaw Telegram channel plugin",
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"@grammyjs/runner": "2.0.3",
|
||||
"@grammyjs/transformer-throttler": "1.2.1",
|
||||
"abort-controller": "3.0.0",
|
||||
"grammy": "1.46.0",
|
||||
|
|
|
|||
|
|
@ -47,7 +47,7 @@ import {
|
|||
} from "./bot-processing-outcome.js";
|
||||
import { createTelegramUpdateTracker } from "./bot-update-tracker.js";
|
||||
import type { TelegramUpdateKeyContext } from "./bot-updates.js";
|
||||
import { apiThrottler, Bot, sequentialize, type ApiClientOptions } from "./bot.runtime.js";
|
||||
import { apiThrottler, Bot, type ApiClientOptions } from "./bot.runtime.js";
|
||||
import type { TelegramBotOptions } from "./bot.types.js";
|
||||
import {
|
||||
setTelegramCallbackQueryAnswerPromise,
|
||||
|
|
@ -69,7 +69,7 @@ import {
|
|||
} from "./poll-answer-context.js";
|
||||
import { formatTelegramRawUpdateForLog } from "./raw-update-log.js";
|
||||
import type { TelegramSendChatActionHandler } from "./sendchataction-401-backoff.js";
|
||||
import { getTelegramSequentialConstraints } from "./sequential-key.js";
|
||||
import { createTelegramSequentializer } from "./sequentialize.js";
|
||||
import { createTelegramThreadBindingManager } from "./thread-bindings.js";
|
||||
|
||||
export async function createTelegramBotCore(
|
||||
|
|
@ -240,7 +240,7 @@ export async function createTelegramBotCore(
|
|||
await next();
|
||||
});
|
||||
|
||||
bot.use(sequentialize(getTelegramSequentialConstraints));
|
||||
bot.use(createTelegramSequentializer());
|
||||
|
||||
// A fast vote can know its route before outbound verification finishes. Hold
|
||||
// only that route's sequential lane until registration succeeds or declines it.
|
||||
|
|
|
|||
|
|
@ -321,18 +321,10 @@ function getRichMessageText(params: RichMessageParams): string {
|
|||
return rich.markdown ?? rich.html ?? "";
|
||||
}
|
||||
|
||||
const runnerHoisted = vi.hoisted(() => ({
|
||||
sequentializeMiddleware: vi.fn(async (_ctx: unknown, next?: () => Promise<void>) => {
|
||||
if (typeof next === "function") {
|
||||
await next();
|
||||
}
|
||||
}),
|
||||
sequentializeSpy: vi.fn(() => runnerHoisted.sequentializeMiddleware),
|
||||
const throttlerHoisted = vi.hoisted(() => ({
|
||||
throttlerSpy: vi.fn(() => "throttler"),
|
||||
}));
|
||||
export const sequentializeSpy: AnyMock = runnerHoisted.sequentializeSpy;
|
||||
export let sequentializeKey: ((ctx: unknown) => string | string[] | undefined) | undefined;
|
||||
export const throttlerSpy: AnyMock = runnerHoisted.throttlerSpy;
|
||||
export const throttlerSpy: AnyMock = throttlerHoisted.throttlerSpy;
|
||||
const telegramBotRuntimeForTest = {
|
||||
Bot: class {
|
||||
api = {
|
||||
|
|
@ -402,17 +394,9 @@ const telegramBotRuntimeForTest = {
|
|||
);
|
||||
}
|
||||
} as unknown as TelegramBotRuntimeForTest["Bot"],
|
||||
sequentialize: ((keyFn: (ctx: unknown) => string | string[] | undefined) => {
|
||||
sequentializeKey = keyFn;
|
||||
return (
|
||||
runnerHoisted.sequentializeSpy as unknown as () => ReturnType<
|
||||
TelegramBotRuntimeForTest["sequentialize"]
|
||||
>
|
||||
)();
|
||||
}) as unknown as TelegramBotRuntimeForTest["sequentialize"],
|
||||
apiThrottler: (() =>
|
||||
(
|
||||
runnerHoisted.throttlerSpy as unknown as () => unknown
|
||||
throttlerHoisted.throttlerSpy as unknown as () => unknown
|
||||
)()) as unknown as TelegramBotRuntimeForTest["apiThrottler"],
|
||||
};
|
||||
export const telegramBotDepsForTest: TelegramBotDeps = {
|
||||
|
|
@ -568,14 +552,5 @@ beforeEach(() => {
|
|||
modelCatalog: [{ provider: "openai", id: "gpt-5.4", name: "GPT-5.4", reasoning: false }],
|
||||
});
|
||||
middlewareUseSpy.mockReset();
|
||||
runnerHoisted.sequentializeMiddleware.mockReset();
|
||||
runnerHoisted.sequentializeMiddleware.mockImplementation(async (_ctx, next) => {
|
||||
if (typeof next === "function") {
|
||||
await next();
|
||||
}
|
||||
});
|
||||
sequentializeSpy.mockReset();
|
||||
sequentializeSpy.mockImplementation(() => runnerHoisted.sequentializeMiddleware);
|
||||
botCtorSpy.mockReset();
|
||||
sequentializeKey = undefined;
|
||||
});
|
||||
|
|
|
|||
|
|
@ -69,7 +69,6 @@ const {
|
|||
onSpy,
|
||||
replySpy,
|
||||
sendMessageSpy,
|
||||
sequentializeSpy,
|
||||
telegramBotDepsForTest,
|
||||
throttlerSpy,
|
||||
useSpy,
|
||||
|
|
@ -209,11 +208,6 @@ async function dispatchSpooledNativeStop(
|
|||
}
|
||||
|
||||
async function setupUpdateOffsetTracker(params: { lastUpdateId: number }) {
|
||||
sequentializeSpy.mockImplementationOnce(
|
||||
() => async (_ctx: unknown, next: () => Promise<void>) => {
|
||||
await next();
|
||||
},
|
||||
);
|
||||
const onUpdateId = vi.fn<(updateId: number) => void | Promise<void>>();
|
||||
await createTelegramBot({
|
||||
token: "tok",
|
||||
|
|
@ -233,34 +227,6 @@ async function runTelegramMiddlewareChain(params: {
|
|||
await runTelegramTestMiddlewareChain(middlewareUseSpy, params.ctx, params.finalHandler);
|
||||
}
|
||||
|
||||
function installPerKeySequentializer(): void {
|
||||
sequentializeSpy.mockImplementationOnce(() => {
|
||||
const lanes = new Map<string, Promise<void>>();
|
||||
return async (ctx: TelegramMiddlewareTestContext, next: () => Promise<void>) => {
|
||||
const constraint = harness.sequentializeKey?.(ctx) ?? "default";
|
||||
const keys = Array.isArray(constraint) ? constraint : [constraint];
|
||||
const previous = Promise.all(keys.map((key) => lanes.get(key) ?? Promise.resolve()));
|
||||
const current = previous.then(async () => {
|
||||
await next();
|
||||
});
|
||||
const tracked = current.catch(() => undefined);
|
||||
for (const key of keys) {
|
||||
lanes.set(key, tracked);
|
||||
}
|
||||
|
||||
try {
|
||||
await current;
|
||||
} finally {
|
||||
for (const key of keys) {
|
||||
if (lanes.get(key) === tracked) {
|
||||
lanes.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async function withTelegramSpooledReplayUpdate<T>(
|
||||
update: object,
|
||||
fn: () => Promise<T>,
|
||||
|
|
@ -386,6 +352,48 @@ describe("createTelegramBot", () => {
|
|||
expectBotClientFields({ apiRoot: "https://api.telegram.org" });
|
||||
});
|
||||
|
||||
it("acknowledges callbacks before waiting for their chat's active message handler", async () => {
|
||||
await createTelegramBot({ token: "tok" });
|
||||
const started = createDeferred<void>();
|
||||
const release = createDeferred<void>();
|
||||
const callbackHandler = vi.fn(async () => {});
|
||||
const message = runTelegramMiddlewareChain({
|
||||
ctx: makePrivateTextContext({ updateId: 1, messageId: 1, chatId: 7, text: "first" }),
|
||||
finalHandler: async () => {
|
||||
started.resolve();
|
||||
await release.promise;
|
||||
},
|
||||
});
|
||||
const runs = [message];
|
||||
|
||||
try {
|
||||
await started.promise;
|
||||
const callback = runTelegramMiddlewareChain({
|
||||
ctx: createTelegramCallbackContext({
|
||||
id: "queued-callback",
|
||||
data: "ordinary-action",
|
||||
updateId: 2,
|
||||
message: { chat: { id: 7, type: "private" } },
|
||||
}),
|
||||
finalHandler: callbackHandler,
|
||||
});
|
||||
runs.push(callback);
|
||||
await runTelegramMiddlewareChain({
|
||||
ctx: makePrivateTextContext({ updateId: 3, messageId: 3, chatId: 8, text: "other chat" }),
|
||||
finalHandler: async () => {},
|
||||
});
|
||||
|
||||
expect(answerCallbackQuerySpy).toHaveBeenCalledExactlyOnceWith("queued-callback");
|
||||
expect(callbackHandler).not.toHaveBeenCalled();
|
||||
release.resolve();
|
||||
await Promise.all(runs);
|
||||
expect(callbackHandler).toHaveBeenCalledOnce();
|
||||
} finally {
|
||||
release.resolve();
|
||||
await Promise.allSettled(runs);
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps poll registry preparation failures retryable during durable replay", async () => {
|
||||
const readError = new Error("poll registry unavailable");
|
||||
const openKeyedStore: TelegramRuntime["state"]["openKeyedStore"] = <T>() => ({
|
||||
|
|
@ -490,7 +498,6 @@ describe("createTelegramBot", () => {
|
|||
|
||||
it("preserves same-chat reply order when a debounced run is still active", async () => {
|
||||
configureOpenDm({ debounceMs: INBOUND_DEBOUNCE_MS, timezone: "envelopeTimezone" });
|
||||
installPerKeySequentializer();
|
||||
|
||||
const setTimeoutSpy = vi.spyOn(globalThis, "setTimeout");
|
||||
const startedBodies: string[] = [];
|
||||
|
|
@ -567,7 +574,6 @@ describe("createTelegramBot", () => {
|
|||
};
|
||||
loadConfig.mockReturnValue(initialConfig);
|
||||
setRuntimeConfigSnapshot(initialConfig, initialConfig);
|
||||
installPerKeySequentializer();
|
||||
vi.useFakeTimers({ toFake: ["Date", "setTimeout", "clearTimeout"] });
|
||||
replySpy.mockResolvedValue(undefined);
|
||||
const sourceWork: Promise<unknown>[] = [];
|
||||
|
|
@ -644,7 +650,6 @@ describe("createTelegramBot", () => {
|
|||
agents: { defaults: { envelopeTimezone: "utc" } },
|
||||
channels: { telegram: { dmPolicy: "open", allowFrom: ["*"] } },
|
||||
});
|
||||
installPerKeySequentializer();
|
||||
vi.useFakeTimers({ toFake: ["Date", "performance", "setTimeout", "clearTimeout"] });
|
||||
vi.setSystemTime(1736380800000);
|
||||
replySpy.mockResolvedValue(undefined);
|
||||
|
|
@ -717,7 +722,6 @@ describe("createTelegramBot", () => {
|
|||
|
||||
it("cancels an expired fragment behind an earlier active message before releasing that message", async () => {
|
||||
configureOpenDm({ debounceMs: 3000, timezone: "envelopeTimezone" });
|
||||
installPerKeySequentializer();
|
||||
vi.useFakeTimers({ toFake: ["Date", "setTimeout", "clearTimeout"] });
|
||||
const earlierStarted = createDeferred<void>();
|
||||
const releaseEarlier = createDeferred<void>();
|
||||
|
|
@ -781,8 +785,6 @@ describe("createTelegramBot", () => {
|
|||
async (stopText) => {
|
||||
configureOpenDm({ debounceMs: INBOUND_DEBOUNCE_MS, timezone: "userTimezone" });
|
||||
|
||||
installPerKeySequentializer();
|
||||
|
||||
const setTimeoutSpy = vi.spyOn(globalThis, "setTimeout");
|
||||
const startedBodies: string[] = [];
|
||||
let reusedWork: Promise<unknown> | undefined;
|
||||
|
|
@ -842,7 +844,6 @@ describe("createTelegramBot", () => {
|
|||
messages: { inbound: { byChannel: { telegram: 3000 } } },
|
||||
channels: { telegram: { dmPolicy: "open", allowFrom: ["*"] } },
|
||||
});
|
||||
installPerKeySequentializer();
|
||||
vi.useFakeTimers({ toFake: ["Date", "setTimeout", "clearTimeout"] });
|
||||
replySpy.mockResolvedValue(undefined);
|
||||
const sourceWork: Promise<unknown>[] = [];
|
||||
|
|
@ -913,7 +914,6 @@ describe("createTelegramBot", () => {
|
|||
},
|
||||
},
|
||||
});
|
||||
installPerKeySequentializer();
|
||||
vi.useFakeTimers({ toFake: ["Date", "setTimeout", "clearTimeout"] });
|
||||
replySpy.mockResolvedValue(undefined);
|
||||
let sourceWork: Promise<unknown> | undefined;
|
||||
|
|
@ -970,7 +970,6 @@ describe("createTelegramBot", () => {
|
|||
},
|
||||
},
|
||||
});
|
||||
installPerKeySequentializer();
|
||||
vi.useFakeTimers({ toFake: ["Date", "setTimeout", "clearTimeout"] });
|
||||
replySpy.mockResolvedValue(undefined);
|
||||
const sourceWork: Promise<unknown>[] = [];
|
||||
|
|
@ -1037,7 +1036,6 @@ describe("createTelegramBot", () => {
|
|||
messages: { inbound: { byChannel: { telegram: 3000 } } },
|
||||
channels: { telegram: { dmPolicy: "open", allowFrom: ["*"] } },
|
||||
});
|
||||
installPerKeySequentializer();
|
||||
vi.useFakeTimers({ toFake: ["Date", "setTimeout", "clearTimeout"] });
|
||||
replySpy.mockResolvedValue(undefined);
|
||||
let sourceWork: Promise<unknown> | undefined;
|
||||
|
|
@ -1081,7 +1079,6 @@ describe("createTelegramBot", () => {
|
|||
|
||||
it("stop cancels ordinary and forwarded batches queued behind an active turn", async () => {
|
||||
configureOpenDm({ debounceMs: 3000, timezone: "envelopeTimezone" });
|
||||
installPerKeySequentializer();
|
||||
const attachmentPath = path.join(
|
||||
requireValue(process.env.OPENCLAW_STATE_DIR, "test state directory"),
|
||||
"caption.txt",
|
||||
|
|
@ -1198,7 +1195,6 @@ describe("createTelegramBot", () => {
|
|||
messages: { inbound: { byChannel: { telegram: 3000 } } },
|
||||
channels: { telegram: { groupPolicy: "open", groups: { "*": { requireMention: false } } } },
|
||||
});
|
||||
installPerKeySequentializer();
|
||||
vi.useFakeTimers({ toFake: ["Date", "setTimeout", "clearTimeout"] });
|
||||
replySpy.mockResolvedValue(undefined);
|
||||
const preparationStarted = createDeferred<void>();
|
||||
|
|
@ -1256,7 +1252,6 @@ describe("createTelegramBot", () => {
|
|||
|
||||
it("keeps separate text-batch replay settlements isolated when the next batch fails", async () => {
|
||||
configureOpenDm({ debounceMs: 300, timezone: "envelopeTimezone" });
|
||||
installPerKeySequentializer();
|
||||
vi.useFakeTimers({ toFake: ["Date", "setTimeout", "clearTimeout"] });
|
||||
const secondDispatchError = new Error("next batch failed before adoption");
|
||||
replySpy.mockResolvedValueOnce(undefined).mockRejectedValueOnce(secondDispatchError);
|
||||
|
|
@ -1299,7 +1294,6 @@ describe("createTelegramBot", () => {
|
|||
it("retries deferred adoption after durable commit fails without settling buffered participants", async () => {
|
||||
configureOpenDm({ debounceMs: INBOUND_DEBOUNCE_MS, timezone: "envelopeTimezone" });
|
||||
|
||||
installPerKeySequentializer();
|
||||
const setTimeoutSpy = vi.spyOn(globalThis, "setTimeout");
|
||||
const commitError = new Error("durable dispatch commit failed");
|
||||
const commitSpy = vi
|
||||
|
|
@ -1371,7 +1365,6 @@ describe("createTelegramBot", () => {
|
|||
it("serializes timeout settlement behind an in-flight durable adoption commit", async () => {
|
||||
configureOpenDm({ debounceMs: INBOUND_DEBOUNCE_MS, timezone: "envelopeTimezone" });
|
||||
|
||||
installPerKeySequentializer();
|
||||
const setTimeoutSpy = vi.spyOn(globalThis, "setTimeout");
|
||||
let markCommitStarted: (() => void) | undefined;
|
||||
let releaseCommit: (() => void) | undefined;
|
||||
|
|
@ -1471,7 +1464,6 @@ describe("createTelegramBot", () => {
|
|||
it("blocks buffered adoption after an exposed replay participant times out", async () => {
|
||||
configureOpenDm({ debounceMs: INBOUND_DEBOUNCE_MS, timezone: "envelopeTimezone" });
|
||||
|
||||
installPerKeySequentializer();
|
||||
const setTimeoutSpy = vi.spyOn(globalThis, "setTimeout");
|
||||
const commitSpy = vi.spyOn(messageDispatchDedupe, "commitTelegramMessageDispatchReplay");
|
||||
let queuedLifecycle: GetReplyOptions["turnAdoptionLifecycle"];
|
||||
|
|
@ -1693,8 +1685,6 @@ describe("createTelegramBot", () => {
|
|||
},
|
||||
});
|
||||
|
||||
installPerKeySequentializer();
|
||||
|
||||
vi.useFakeTimers({ toFake: ["Date", "setTimeout", "clearTimeout"] });
|
||||
replySpy.mockResolvedValue(undefined);
|
||||
let pendingWork: Promise<unknown> | undefined;
|
||||
|
|
@ -3671,12 +3661,6 @@ describe("createTelegramBot", () => {
|
|||
});
|
||||
|
||||
it("treats permanent command pagination edit failures as completed updates", async () => {
|
||||
sequentializeSpy.mockImplementationOnce(
|
||||
() => async (_ctx: unknown, next: () => Promise<void>) => {
|
||||
await next();
|
||||
},
|
||||
);
|
||||
|
||||
const onUpdateId = vi.fn();
|
||||
await createTelegramBot({
|
||||
token: "tok",
|
||||
|
|
|
|||
|
|
@ -171,7 +171,6 @@ const telegramBotRuntimeForTest: TelegramBotRuntimeForTest = {
|
|||
catch = vi.fn();
|
||||
constructor(public token: string) {}
|
||||
} as unknown as TelegramBotRuntimeForTest["Bot"],
|
||||
sequentialize: (() => vi.fn()) as TelegramBotRuntimeForTest["sequentialize"],
|
||||
apiThrottler: (() => throttlerSpy()) as unknown as TelegramBotRuntimeForTest["apiThrottler"],
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
export { sequentialize } from "@grammyjs/runner";
|
||||
export { apiThrottler } from "@grammyjs/transformer-throttler";
|
||||
export { Bot } from "grammy";
|
||||
export type { ApiClientOptions } from "grammy";
|
||||
|
|
|
|||
140
extensions/telegram/src/sequentialize.test.ts
Normal file
140
extensions/telegram/src/sequentialize.test.ts
Normal file
|
|
@ -0,0 +1,140 @@
|
|||
import { Bot, type MiddlewareFn } from "grammy";
|
||||
import type { Chat, Update } from "grammy/types";
|
||||
import { createDeferred } from "openclaw/plugin-sdk/extension-shared";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { telegramBotInfoForTest } from "./bot.create-telegram-bot.test-support.js";
|
||||
import { createTelegramSequentializer } from "./sequentialize.js";
|
||||
|
||||
const user = { id: 42, is_bot: false, first_name: "Ada" };
|
||||
|
||||
const chat: Chat.SupergroupChat = {
|
||||
id: -1001,
|
||||
type: "supergroup",
|
||||
title: "Sequencing",
|
||||
is_forum: true,
|
||||
};
|
||||
|
||||
function topicMessage(updateId: number, messageId: number, topicId: number): Update {
|
||||
return {
|
||||
update_id: updateId,
|
||||
message: {
|
||||
chat,
|
||||
from: user,
|
||||
message_id: messageId,
|
||||
message_thread_id: topicId,
|
||||
is_topic_message: true,
|
||||
date: 1,
|
||||
text: "message",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function reaction(updateId: number, messageId: number): Update {
|
||||
return {
|
||||
update_id: updateId,
|
||||
message_reaction: {
|
||||
chat,
|
||||
message_id: messageId,
|
||||
date: 1,
|
||||
user,
|
||||
old_reaction: [],
|
||||
new_reaction: [{ type: "emoji", emoji: "👍" }],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createSequencedBot(handler: MiddlewareFn) {
|
||||
const bot = new Bot("123:test", { botInfo: telegramBotInfoForTest });
|
||||
bot.use(createTelegramSequentializer());
|
||||
bot.use(handler);
|
||||
return bot;
|
||||
}
|
||||
|
||||
describe("Telegram sequential middleware", () => {
|
||||
it("reserves overlapping lanes in FIFO order without blocking other topics or deleting newer tails", async () => {
|
||||
const firstGate = createDeferred<void>();
|
||||
const secondGate = createDeferred<void>();
|
||||
const secondStarted = createDeferred<void>();
|
||||
const started: number[] = [];
|
||||
const bot = createSequencedBot(async (ctx) => {
|
||||
const id = ctx.update.update_id;
|
||||
started.push(id);
|
||||
if (id === 1) {
|
||||
await firstGate.promise;
|
||||
} else if (id === 2) {
|
||||
secondStarted.resolve();
|
||||
await secondGate.promise;
|
||||
}
|
||||
});
|
||||
const first = bot.handleUpdate(topicMessage(1, 77, 9));
|
||||
const second = bot.handleUpdate(topicMessage(2, 78, 9));
|
||||
const queuedReaction = bot.handleUpdate(reaction(3, 78));
|
||||
const unrelated = bot.handleUpdate(topicMessage(4, 79, 10));
|
||||
const runs = [first, second, queuedReaction, unrelated];
|
||||
|
||||
try {
|
||||
await unrelated;
|
||||
expect(started).toEqual([1, 4]);
|
||||
|
||||
firstGate.resolve();
|
||||
await first;
|
||||
await secondStarted.promise;
|
||||
const third = bot.handleUpdate(topicMessage(5, 80, 9));
|
||||
const anotherTopic = bot.handleUpdate(topicMessage(6, 81, 10));
|
||||
runs.push(third, anotherTopic);
|
||||
await anotherTopic;
|
||||
expect(started).toEqual([1, 4, 2, 6]);
|
||||
|
||||
secondGate.resolve();
|
||||
await Promise.all(runs);
|
||||
expect(started.slice(4).toSorted((left, right) => left - right)).toEqual([3, 5]);
|
||||
} finally {
|
||||
firstGate.resolve();
|
||||
secondGate.resolve();
|
||||
await Promise.allSettled(runs);
|
||||
}
|
||||
});
|
||||
|
||||
it.each(["throw", "reject"])(
|
||||
"recovers from a handler %s without releasing a different unfinished constraint",
|
||||
async (failure) => {
|
||||
const gate = createDeferred<void>();
|
||||
const error = new Error("reaction failed");
|
||||
const started: number[] = [];
|
||||
const bot = createSequencedBot((ctx) => {
|
||||
const id = ctx.update.update_id;
|
||||
started.push(id);
|
||||
if (id === 1) {
|
||||
return gate.promise;
|
||||
}
|
||||
if (id === 2) {
|
||||
if (failure === "throw") {
|
||||
throw error;
|
||||
}
|
||||
return Promise.reject(error);
|
||||
}
|
||||
return Promise.resolve();
|
||||
});
|
||||
const topic = bot.handleUpdate(topicMessage(1, 76, 9));
|
||||
const failedReaction = bot.handleUpdate(reaction(2, 77));
|
||||
const rejection = expect(failedReaction).rejects.toMatchObject({ error });
|
||||
const joined = bot.handleUpdate(topicMessage(3, 77, 9));
|
||||
const runs = [topic, failedReaction, joined];
|
||||
|
||||
try {
|
||||
await rejection;
|
||||
await bot.handleUpdate(topicMessage(4, 78, 10));
|
||||
expect(started).toEqual([1, 2, 4]);
|
||||
|
||||
gate.resolve();
|
||||
await Promise.all([topic, joined]);
|
||||
expect(started).toEqual([1, 2, 4, 3]);
|
||||
await bot.handleUpdate(topicMessage(5, 77, 9));
|
||||
expect(started).toEqual([1, 2, 4, 3, 5]);
|
||||
} finally {
|
||||
gate.resolve();
|
||||
await Promise.allSettled(runs);
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
31
extensions/telegram/src/sequentialize.ts
Normal file
31
extensions/telegram/src/sequentialize.ts
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
import { getTelegramSequentialConstraints } from "./sequential-key.js";
|
||||
|
||||
export function createTelegramSequentializer() {
|
||||
const tails = new Map<string, Promise<void>>();
|
||||
return async (
|
||||
ctx: Parameters<typeof getTelegramSequentialConstraints>[0],
|
||||
next: () => Promise<void>,
|
||||
): Promise<void> => {
|
||||
const constraints = getTelegramSequentialConstraints(ctx);
|
||||
const keys = Array.isArray(constraints) ? constraints : [constraints];
|
||||
const previous = keys.map((key) => tails.get(key)).filter((tail) => tail !== undefined);
|
||||
const task = Promise.all(previous).then(next);
|
||||
const tail = task.then(
|
||||
() => undefined,
|
||||
() => undefined,
|
||||
);
|
||||
// Reserve all keys before yielding, including keys whose previous work is still waiting.
|
||||
for (const key of keys) {
|
||||
tails.set(key, tail);
|
||||
}
|
||||
try {
|
||||
await task;
|
||||
} finally {
|
||||
for (const key of keys) {
|
||||
if (tails.get(key) === tail) {
|
||||
tails.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
@ -2260,7 +2260,6 @@
|
|||
"@clack/prompts": "1.8.1",
|
||||
"@earendil-works/pi-tui": "0.85.1",
|
||||
"@google/genai": "2.23.0",
|
||||
"@grammyjs/runner": "2.0.3",
|
||||
"@grammyjs/transformer-throttler": "1.2.1",
|
||||
"@homebridge/ciao": "1.3.12",
|
||||
"@lydell/node-pty": "1.2.0-beta.15",
|
||||
|
|
@ -2299,14 +2298,12 @@
|
|||
"kysely": "0.29.6",
|
||||
"linkedom": "0.18.13",
|
||||
"minimatch": "10.2.6",
|
||||
"ms": "2.1.3",
|
||||
"node-edge-tts": "1.2.10",
|
||||
"openai": "7.18.0",
|
||||
"p-limit": "7.3.2",
|
||||
"p-map": "7.0.8",
|
||||
"partial-json": "0.1.7",
|
||||
"playwright-core": "1.63.0",
|
||||
"pretty-ms": "9.3.1",
|
||||
"qrcode": "1.5.4",
|
||||
"rastermill": "0.3.3",
|
||||
"semver": "7.8.5",
|
||||
|
|
@ -2342,7 +2339,6 @@
|
|||
"@types/jsdom": "30.0.0",
|
||||
"@types/markdown-it": "14.2.0",
|
||||
"@types/mdast": "4.0.4",
|
||||
"@types/ms": "2.1.0",
|
||||
"@types/node": "26.6.1",
|
||||
"@types/semver": "7.8.0",
|
||||
"@types/web-push": "3.6.4",
|
||||
|
|
|
|||
34
pnpm-lock.yaml
generated
34
pnpm-lock.yaml
generated
|
|
@ -251,9 +251,6 @@ importers:
|
|||
'@google/genai':
|
||||
specifier: 2.23.0
|
||||
version: 2.23.0(@modelcontextprotocol/sdk@1.30.0(supports-color@10.2.2)(zod@4.6.5))(supports-color@10.2.2)
|
||||
'@grammyjs/runner':
|
||||
specifier: 2.0.3
|
||||
version: 2.0.3(grammy@1.46.0(supports-color@10.2.2))
|
||||
'@grammyjs/transformer-throttler':
|
||||
specifier: 1.2.1
|
||||
version: 1.2.1(grammy@1.46.0(supports-color@10.2.2))
|
||||
|
|
@ -368,9 +365,6 @@ importers:
|
|||
minimatch:
|
||||
specifier: 10.2.6
|
||||
version: 10.2.6
|
||||
ms:
|
||||
specifier: 2.1.3
|
||||
version: 2.1.3
|
||||
node-edge-tts:
|
||||
specifier: 1.2.10
|
||||
version: 1.2.10(supports-color@10.2.2)
|
||||
|
|
@ -389,9 +383,6 @@ importers:
|
|||
playwright-core:
|
||||
specifier: 1.63.0
|
||||
version: 1.63.0
|
||||
pretty-ms:
|
||||
specifier: 9.3.1
|
||||
version: 9.3.1
|
||||
qrcode:
|
||||
specifier: 1.5.4
|
||||
version: 1.5.4
|
||||
|
|
@ -492,9 +483,6 @@ importers:
|
|||
'@types/mdast':
|
||||
specifier: 4.0.4
|
||||
version: 4.0.4
|
||||
'@types/ms':
|
||||
specifier: 2.1.0
|
||||
version: 2.1.0
|
||||
'@types/node':
|
||||
specifier: 26.6.1
|
||||
version: 26.6.1
|
||||
|
|
@ -2240,9 +2228,6 @@ importers:
|
|||
|
||||
extensions/telegram:
|
||||
dependencies:
|
||||
'@grammyjs/runner':
|
||||
specifier: 2.0.3
|
||||
version: 2.0.3(grammy@1.46.0(supports-color@10.2.2))
|
||||
'@grammyjs/transformer-throttler':
|
||||
specifier: 1.2.1
|
||||
version: 1.2.1(grammy@1.46.0(supports-color@10.2.2))
|
||||
|
|
@ -2905,9 +2890,6 @@ importers:
|
|||
markdown-it-emoji:
|
||||
specifier: 3.1.0
|
||||
version: 3.1.0
|
||||
markdown-it-task-lists:
|
||||
specifier: 2.1.1
|
||||
version: 2.1.1
|
||||
parse5:
|
||||
specifier: 8.0.1
|
||||
version: 8.0.1
|
||||
|
|
@ -3904,12 +3886,6 @@ packages:
|
|||
'@modelcontextprotocol/sdk':
|
||||
optional: true
|
||||
|
||||
'@grammyjs/runner@2.0.3':
|
||||
resolution: {integrity: sha512-nckmTs1dPWfVQteK9cxqxzE+0m1VRvluLWB8UgFzsjg62w3qthPJt0TYtJBEdG7OedvfQq4vnFAyE6iaMkR42A==}
|
||||
engines: {node: '>=12.20.0 || >=14.13.1'}
|
||||
peerDependencies:
|
||||
grammy: ^1.13.1
|
||||
|
||||
'@grammyjs/transformer-throttler@1.2.1':
|
||||
resolution: {integrity: sha512-CpWB0F3rJdUiKsq7826QhQsxbZi4wqfz1ccKX+fr+AOC+o8K7ZvS+wqX0suSu1QCsyUq2MDpNiKhyL2ZOJUS4w==}
|
||||
engines: {node: ^12.20.0 || >=14.13.1}
|
||||
|
|
@ -8221,9 +8197,6 @@ packages:
|
|||
markdown-it-emoji@3.1.0:
|
||||
resolution: {integrity: sha512-NhmMEH2ywduD4Nty1E8uB5NqfLhAT1VR0dyvoJyStKOqCzbZmVdn/+8wj7zpDsb/fLBikpCPsWwxqKlvMmbz4g==}
|
||||
|
||||
markdown-it-task-lists@2.1.1:
|
||||
resolution: {integrity: sha512-TxFAc76Jnhb2OUu+n3yz9RMu4CwGfaT788br6HhEDlvWfdeJcLUsxk1Hgw2yJio0OXsxv7pyIPmvECY7bMbluA==}
|
||||
|
||||
markdown-it@15.0.2:
|
||||
resolution: {integrity: sha512-q4IGxMv56jCqT4OCRCADBoDP3LO4MhmTXjFbphHPXs4g3j9Xg5RDnxqN8IF/3vIWEU+VCnUq+7JUg/cfy2E6Qw==}
|
||||
hasBin: true
|
||||
|
|
@ -11416,11 +11389,6 @@ snapshots:
|
|||
- supports-color
|
||||
- utf-8-validate
|
||||
|
||||
'@grammyjs/runner@2.0.3(grammy@1.46.0(supports-color@10.2.2))':
|
||||
dependencies:
|
||||
abort-controller: 3.0.0
|
||||
grammy: 1.46.0(supports-color@10.2.2)
|
||||
|
||||
'@grammyjs/transformer-throttler@1.2.1(grammy@1.46.0(supports-color@10.2.2))':
|
||||
dependencies:
|
||||
bottleneck: 2.19.5
|
||||
|
|
@ -15820,8 +15788,6 @@ snapshots:
|
|||
|
||||
markdown-it-emoji@3.1.0: {}
|
||||
|
||||
markdown-it-task-lists@2.1.1: {}
|
||||
|
||||
markdown-it@15.0.2:
|
||||
dependencies:
|
||||
argparse: 3.0.2
|
||||
|
|
|
|||
|
|
@ -45,13 +45,6 @@ const EXTERNAL_CLASS_FAMILIES: ExternalClassFamily[] = [
|
|||
matches: (className) => className.startsWith("ProseMirror"),
|
||||
producer: "ProseMirror editor DOM",
|
||||
},
|
||||
// markdown-it-task-lists emits these two classes from parsed markdown.
|
||||
{
|
||||
matches: (className) =>
|
||||
className === "task-list-item" || className === "task-list-item-checkbox",
|
||||
producer:
|
||||
"markdown-it-task-lists via ui/src/components/markdown-parser.ts (the contains-task-list class is removed there)",
|
||||
},
|
||||
];
|
||||
|
||||
type SourceReferences = {
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@
|
|||
|
||||
import type { SpawnSyncOptions } from "node:child_process";
|
||||
import { performance } from "node:perf_hooks";
|
||||
import prettyMilliseconds from "pretty-ms";
|
||||
import { resolveNodeRuntimeExecutable } from "../src/infra/node-runtime-executable.ts";
|
||||
import {
|
||||
finalizeBuildStepCache,
|
||||
|
|
@ -17,6 +16,7 @@ import {
|
|||
distArtifactEntryArgs,
|
||||
withDistArtifactOwnership,
|
||||
} from "./lib/dist-artifact-ownership.mts";
|
||||
import { formatDurationElapsed } from "./lib/format-duration.mts";
|
||||
import { runManagedCommand } from "./lib/managed-child-process.mts";
|
||||
import type { MemoryLimitParams } from "./lib/process-memory.mts";
|
||||
import { preflightInstalledSourceArtifacts } from "./lib/source-update-artifact-preflight.mts";
|
||||
|
|
@ -492,7 +492,7 @@ export function formatBuildAllDuration(durationMs: number) {
|
|||
: clampedMs < 10_000
|
||||
? Math.round(clampedMs / 10) * 10
|
||||
: Math.round(clampedMs / 100) * 100;
|
||||
return prettyMilliseconds(roundedMs, {
|
||||
return formatDurationElapsed(roundedMs, {
|
||||
secondsDecimalDigits: clampedMs < 10_000 ? 2 : 1,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import path from "node:path";
|
|||
import { fileURLToPath } from "node:url";
|
||||
import { createControlUiPrecompressedAssetVariants } from "../ui/vite.config.ts";
|
||||
import { assertRealOutputRoot } from "./lib/output-root-guard.mjs";
|
||||
import { isRecord } from "./lib/record-shared.mjs";
|
||||
|
||||
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const require = createRequire(path.join(repoRoot, "ui/package.json"));
|
||||
|
|
@ -42,6 +43,29 @@ function resolveCommit(ref: string): string {
|
|||
return result.stdout.trim();
|
||||
}
|
||||
|
||||
function declaredDependencyNames(root: string): string[] {
|
||||
const file = path.join(root, "package.json");
|
||||
if (!fs.existsSync(file)) {
|
||||
return [];
|
||||
}
|
||||
const manifest: unknown = JSON.parse(fs.readFileSync(file, "utf8"));
|
||||
if (!isRecord(manifest)) {
|
||||
throw new Error(`Invalid package manifest: ${file}`);
|
||||
}
|
||||
const names: string[] = [];
|
||||
for (const key of ["dependencies", "devDependencies", "optionalDependencies"]) {
|
||||
const entries = manifest[key];
|
||||
if (entries === undefined) {
|
||||
continue;
|
||||
}
|
||||
if (!isRecord(entries)) {
|
||||
throw new Error(`Invalid ${key} in package manifest: ${file}`);
|
||||
}
|
||||
names.push(...Object.keys(entries));
|
||||
}
|
||||
return names;
|
||||
}
|
||||
|
||||
function linkDependencies(baseRoot: string): void {
|
||||
const roots = ["", "ui"];
|
||||
for (const parent of ["packages", "extensions"]) {
|
||||
|
|
@ -51,6 +75,18 @@ function linkDependencies(baseRoot: string): void {
|
|||
}
|
||||
}
|
||||
}
|
||||
if (
|
||||
roots.some((root) => {
|
||||
const candidate = new Set(declaredDependencyNames(path.join(repoRoot, root)));
|
||||
return declaredDependencyNames(path.join(baseRoot, root)).some(
|
||||
(name) => !candidate.has(name),
|
||||
);
|
||||
})
|
||||
) {
|
||||
// A removal must not make the historical source unbuildable. Materialize
|
||||
// its lockfile in this private archive without running historical hooks.
|
||||
run("pnpm", ["install", "--frozen-lockfile", "--ignore-scripts"], baseRoot);
|
||||
}
|
||||
const workspaceRoots = new Map(
|
||||
roots.map((root) => [fs.realpathSync(path.join(repoRoot, root)), root]),
|
||||
);
|
||||
|
|
@ -74,6 +110,7 @@ function linkDependencies(baseRoot: string): void {
|
|||
const target = workspace === undefined ? installed : path.join(baseRoot, workspace);
|
||||
const destination = path.join(destinationRoot, "node_modules", name);
|
||||
fs.mkdirSync(path.dirname(destination), { recursive: true });
|
||||
fs.rmSync(destination, { recursive: true, force: true });
|
||||
fs.symlinkSync(target, destination, "junction");
|
||||
}
|
||||
}
|
||||
|
|
@ -114,8 +151,9 @@ function main(): void {
|
|||
run("tar", ["-xzf", archive, "-C", baseRoot]);
|
||||
linkDependencies(baseRoot);
|
||||
|
||||
// Both builds use the candidate's dependency installation. Calling Vite
|
||||
// directly keeps historical policy out; one identity isolates source bytes.
|
||||
// Both builds use the candidate's toolchain and shared dependencies; only
|
||||
// base-only dependencies come from its lockfile. Calling Vite directly
|
||||
// keeps historical policy out; one identity isolates source bytes.
|
||||
for (const root of [repoRoot, baseRoot]) {
|
||||
run(process.execPath, [viteBin, "build"], path.join(root, "ui"), buildEnv);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,9 +13,9 @@ import {
|
|||
} from "@openclaw/normalization-core/number-coercion";
|
||||
import { isRecord } from "@openclaw/normalization-core/record-coerce";
|
||||
import { readStringValue } from "@openclaw/normalization-core/string-coerce";
|
||||
import prettyMilliseconds from "pretty-ms";
|
||||
import { stripLeadingPackageManagerSeparator } from "../../lib/arg-utils.mts";
|
||||
import { resolveProviderConfig } from "../../lib/cross-os-release-checks/config.ts";
|
||||
import { formatDurationElapsed } from "../../lib/format-duration.mts";
|
||||
import {
|
||||
die,
|
||||
ensureValue,
|
||||
|
|
@ -558,13 +558,10 @@ function formatDuration(durationMs: number): string {
|
|||
return "0ms";
|
||||
}
|
||||
const roundedMs = Math.round(durationMs);
|
||||
if (roundedMs < 1000) {
|
||||
return prettyMilliseconds(roundedMs);
|
||||
}
|
||||
return prettyMilliseconds(Math.round(durationMs / 1000) * 1000, {
|
||||
hideYear: true,
|
||||
unitCount: 2,
|
||||
});
|
||||
return formatDurationElapsed(
|
||||
roundedMs < 1000 ? roundedMs : Math.round(durationMs / 1000) * 1000,
|
||||
{ showYears: false, unitCount: 2 },
|
||||
);
|
||||
}
|
||||
|
||||
function readHarnessCheckoutVersion(): string {
|
||||
|
|
|
|||
|
|
@ -51,11 +51,6 @@
|
|||
"class": "default-runtime-initially",
|
||||
"risk": ["provider-sdk", "network", "realtime"]
|
||||
},
|
||||
"@grammyjs/runner": {
|
||||
"owner": "plugin:telegram",
|
||||
"class": "plugin-runtime",
|
||||
"risk": ["telegram-bot-api", "polling"]
|
||||
},
|
||||
"@grammyjs/transformer-throttler": {
|
||||
"owner": "plugin:telegram",
|
||||
"class": "plugin-runtime",
|
||||
|
|
@ -240,11 +235,6 @@
|
|||
"class": "core-runtime",
|
||||
"risk": ["pattern-matching"]
|
||||
},
|
||||
"ms": {
|
||||
"owner": "core:duration-parsing",
|
||||
"class": "core-runtime",
|
||||
"risk": ["duration-parser"]
|
||||
},
|
||||
"node-edge-tts": {
|
||||
"owner": "plugin:microsoft",
|
||||
"class": "plugin-runtime",
|
||||
|
|
@ -275,11 +265,6 @@
|
|||
"class": "core-runtime",
|
||||
"risk": ["browser-automation", "cdp"]
|
||||
},
|
||||
"pretty-ms": {
|
||||
"owner": "core:duration-formatting",
|
||||
"class": "core-runtime",
|
||||
"risk": ["formatting"]
|
||||
},
|
||||
"quickjs-wasi": {
|
||||
"owner": "plugin:code-mode-quickjs",
|
||||
"class": "plugin-runtime",
|
||||
|
|
|
|||
46
scripts/lib/format-duration.mts
Normal file
46
scripts/lib/format-duration.mts
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
import { durationUnitMs } from "../../src/infra/format-time/duration-units.ts";
|
||||
import { resolveExactDurationParts } from "../../src/infra/format-time/format-duration-exact.ts";
|
||||
import {
|
||||
formatDurationParts,
|
||||
type DurationPart,
|
||||
} from "../../src/infra/format-time/format-duration-internal.ts";
|
||||
|
||||
/** Formats non-negative, pre-rounded script timings with fractional seconds. */
|
||||
export function formatDurationElapsed(
|
||||
ms: number,
|
||||
options: { secondsDecimalDigits?: 1 | 2; unitCount?: 1 | 2; showYears?: boolean } = {},
|
||||
): string {
|
||||
const exact = resolveExactDurationParts(ms);
|
||||
if (!exact) {
|
||||
throw new TypeError("Expected a non-negative finite number");
|
||||
}
|
||||
if (ms < durationUnitMs.second) {
|
||||
return `${ms}ms`;
|
||||
}
|
||||
const daysPerYear = BigInt(durationUnitMs.year / durationUnitMs.day);
|
||||
const wholeParts = exact.flatMap(({ value, unit }): DurationPart[] => {
|
||||
if (unit === "second" || unit === "millisecond") {
|
||||
return [];
|
||||
}
|
||||
if (unit !== "day" || options.showYears === false) {
|
||||
return [{ value, unit }];
|
||||
}
|
||||
const days = BigInt(value);
|
||||
const dayParts: DurationPart[] = [
|
||||
{ value: days / daysPerYear, unit: "year" },
|
||||
{ value: days % daysPerYear, unit: "day" },
|
||||
];
|
||||
return dayParts.filter((part) => part.value !== 0n);
|
||||
});
|
||||
const decimals = options.secondsDecimalDigits ?? 1;
|
||||
const scale = 10 ** decimals;
|
||||
// Avoid dropping a displayed decimal when binary division lands just below it.
|
||||
const seconds = Math.floor(((ms / durationUnitMs.second) % 60) * scale + 0.000_000_1) / scale;
|
||||
const unitCount = options.unitCount ?? Infinity;
|
||||
const units = wholeParts.slice(0, unitCount);
|
||||
const result = [formatDurationParts(units)];
|
||||
if (seconds > 0 && units.length < unitCount) {
|
||||
result.push(`${seconds.toFixed(decimals).replace(/\.0+$/, "")}s`);
|
||||
}
|
||||
return result.filter(Boolean).join(" ") || "0ms";
|
||||
}
|
||||
|
|
@ -35,7 +35,6 @@ const dependencies = [
|
|||
"json5",
|
||||
"koffi",
|
||||
"kysely",
|
||||
"ms",
|
||||
"p-map",
|
||||
"semver",
|
||||
"string-width",
|
||||
|
|
|
|||
|
|
@ -117,6 +117,7 @@ scripts/lib/direct-run.mjs
|
|||
scripts/lib/error-format.mts
|
||||
scripts/lib/extension-test-plan.mts
|
||||
scripts/lib/failed-trailer.mts
|
||||
scripts/lib/format-duration.mts
|
||||
scripts/lib/gateway-server-test-plan.mts
|
||||
scripts/lib/list-test-files.mts
|
||||
scripts/lib/local-check-runtime.mts
|
||||
|
|
@ -648,6 +649,9 @@ src/infra/file-lock-manager.ts
|
|||
src/infra/file-lock-sync.ts
|
||||
src/infra/file-lock.ts
|
||||
src/infra/fixed-window-rate-limit.ts
|
||||
src/infra/format-time/duration-units.ts
|
||||
src/infra/format-time/format-duration-exact.ts
|
||||
src/infra/format-time/format-duration-internal.ts
|
||||
src/infra/fs-safe-copy.worker.ts
|
||||
src/infra/fs-safe.ts
|
||||
src/infra/gateway-lock-process.ts
|
||||
|
|
|
|||
|
|
@ -4,12 +4,12 @@
|
|||
import { spawn, type ChildProcess } from "node:child_process";
|
||||
import { performance } from "node:perf_hooks";
|
||||
import pMap from "p-map";
|
||||
import prettyMilliseconds from "pretty-ms";
|
||||
import {
|
||||
MAX_TIMER_TIMEOUT_MS,
|
||||
resolveTimerTimeoutMs,
|
||||
} from "../packages/normalization-core/src/number-coercion.ts";
|
||||
import { isDirectRunUrl } from "./lib/direct-run.mjs";
|
||||
import { formatDurationElapsed } from "./lib/format-duration.mts";
|
||||
import {
|
||||
inspectManagedProcessGroup,
|
||||
terminateManagedChild,
|
||||
|
|
@ -448,7 +448,7 @@ function formatDuration(ms: number) {
|
|||
return "";
|
||||
}
|
||||
const roundedMs = ms < 1000 ? Math.round(ms) : Math.round(ms / 100) * 100;
|
||||
return prettyMilliseconds(Math.max(0, roundedMs), {
|
||||
return formatDurationElapsed(Math.max(0, roundedMs), {
|
||||
unitCount: 1,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
103
src/agents/failover/retry-evidence.test.ts
Normal file
103
src/agents/failover/retry-evidence.test.ts
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { classifyRateLimitWindow, resolveRetryAfterMs } from "./retry-evidence.js";
|
||||
|
||||
const NOW_MS = Date.parse("2015-10-21T07:27:00.000Z");
|
||||
|
||||
describe("resolveRetryAfterMs", () => {
|
||||
it.each([
|
||||
["1001 milliseconds", 1001],
|
||||
["1001 millisecond", 1001],
|
||||
["1001 msecs", 1001],
|
||||
["1001 msec", 1001],
|
||||
["1001ms", 1001],
|
||||
["1.25 seconds", 1250],
|
||||
["1.25 second", 1250],
|
||||
["1.25 secs", 1250],
|
||||
["1.25 sec", 1250],
|
||||
["1.25s", 1250],
|
||||
["1.5 minutes", 90_000],
|
||||
["1.5 minute", 90_000],
|
||||
["1.5 mins", 90_000],
|
||||
["1.5 min", 90_000],
|
||||
["1.5m", 90_000],
|
||||
["0.25 hours", 900_000],
|
||||
["0.25 hour", 900_000],
|
||||
["0.25 hrs", 900_000],
|
||||
["0.25 hr", 900_000],
|
||||
["0.25h", 900_000],
|
||||
["0.5 days", 43_200_000],
|
||||
["0.5 day", 43_200_000],
|
||||
["0.5d", 43_200_000],
|
||||
["1.25", 1250],
|
||||
["0.01ms", 1],
|
||||
["0s", 0],
|
||||
] as const)("converts the provider retry floor %s", (value, expected) => {
|
||||
expect(resolveRetryAfterMs(`Retry-After: ${value}`, NOW_MS)).toBe(expected);
|
||||
});
|
||||
|
||||
it("keeps the largest floor across message variants and headers", () => {
|
||||
const message =
|
||||
"Retry-After: 0.5 seconds; please try again in 1.25 SECS, then continue.\nRetry after in 1s";
|
||||
expect(resolveRetryAfterMs(message, NOW_MS)).toBe(1250);
|
||||
expect(
|
||||
resolveRetryAfterMs(message, NOW_MS, {
|
||||
headers: { "retry-after": "2", "retry-after-ms": "2500.5" },
|
||||
}),
|
||||
).toBe(2501);
|
||||
expect(
|
||||
resolveRetryAfterMs(message, NOW_MS, JSON.stringify({ headers: { "retry-after": "1" } })),
|
||||
).toBe(1250);
|
||||
});
|
||||
|
||||
it.each(["Infinity", "iNfInItY milliseconds", `${"9".repeat(400)} days`])(
|
||||
"preserves an unbounded provider retry floor: %s",
|
||||
(value) => {
|
||||
const message = `Retry-After: ${value}`;
|
||||
expect(resolveRetryAfterMs(message, NOW_MS)).toBe(Infinity);
|
||||
expect(classifyRateLimitWindow(message, NOW_MS)).toEqual({ kind: "long" });
|
||||
},
|
||||
);
|
||||
|
||||
it("does not apply CLI token-length or safe-integer limits to retry floors", () => {
|
||||
expect(resolveRetryAfterMs(`Retry-After: ${"0".repeat(101)}2s`, NOW_MS)).toBe(2000);
|
||||
expect(resolveRetryAfterMs("Retry-After: 9007199254741 seconds", NOW_MS)).toBe(
|
||||
9_007_199_254_741_000,
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
"Wed, 21 Oct 2015 07:28:00 GMT",
|
||||
"Wednesday, 21-Oct-15 07:28:00 GMT",
|
||||
"Wed Oct 21 07:28:00 2015",
|
||||
])("preserves HTTP-date retry floors: %s", (date) => {
|
||||
expect(resolveRetryAfterMs(`Retry-After: ${date}`, NOW_MS)).toBe(60_000);
|
||||
});
|
||||
|
||||
it("clamps elapsed HTTP dates to zero and rejects invalid dates", () => {
|
||||
expect(resolveRetryAfterMs("Retry-After: Wed, 21 Oct 2015 07:26:00 GMT", NOW_MS)).toBe(0);
|
||||
expect(
|
||||
resolveRetryAfterMs("Retry-After: Tue, 21 Oct 2015 07:28:00 GMT", NOW_MS),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it.each(["1 week", "1y", "1 month", "1 constructor", "-1s", ".5s", "1e3s", "1h30m"])(
|
||||
"rejects values outside the retry grammar: %s",
|
||||
(value) => {
|
||||
expect(resolveRetryAfterMs(`Retry-After: ${value}`, NOW_MS)).toBeUndefined();
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("classifyRateLimitWindow", () => {
|
||||
it("compares converted fractions against the one-minute short-window limit", () => {
|
||||
expect(classifyRateLimitWindow("429 Try again in 0.5 minutes", NOW_MS)).toEqual({
|
||||
kind: "short",
|
||||
retryAfterSeconds: 30,
|
||||
});
|
||||
expect(classifyRateLimitWindow("429 Retry-After: 60000ms", NOW_MS)).toEqual({
|
||||
kind: "short",
|
||||
retryAfterSeconds: 60,
|
||||
});
|
||||
expect(classifyRateLimitWindow("429 Retry-After: 60001ms", NOW_MS)).toEqual({ kind: "long" });
|
||||
});
|
||||
});
|
||||
|
|
@ -3,7 +3,7 @@ import {
|
|||
parseRetryAfterErrorSeconds,
|
||||
} from "@openclaw/ai/internal/retry-after";
|
||||
import { safeParseJsonRecord } from "@openclaw/normalization-core/json-coercion";
|
||||
import milliseconds from "ms";
|
||||
import { durationUnitMs } from "../../infra/format-time/duration-units.js";
|
||||
import { isTransientNetworkError } from "../../infra/retryable-network-errors.js";
|
||||
import {
|
||||
extractErrorHttpStatus,
|
||||
|
|
@ -34,6 +34,31 @@ const SHORT_WINDOW_RATE_LIMIT_RE =
|
|||
const RETRY_AFTER_VALUE_RE =
|
||||
/\b(?:retry[- ]after\b\s*:?\s*(?:in\b\s*)?|(?:please\s+)?try again in\s+)([^\r\n;]+)/i;
|
||||
const RETRY_AFTER_NUMBER_RE = /^(\d+(?:\.\d+)?|Infinity)\s*([a-z]+)?\b/i;
|
||||
const RETRY_AFTER_UNIT_MS = new Map<string, number>([
|
||||
["milliseconds", durationUnitMs.millisecond],
|
||||
["millisecond", durationUnitMs.millisecond],
|
||||
["msecs", durationUnitMs.millisecond],
|
||||
["msec", durationUnitMs.millisecond],
|
||||
["ms", durationUnitMs.millisecond],
|
||||
["seconds", durationUnitMs.second],
|
||||
["second", durationUnitMs.second],
|
||||
["secs", durationUnitMs.second],
|
||||
["sec", durationUnitMs.second],
|
||||
["s", durationUnitMs.second],
|
||||
["minutes", durationUnitMs.minute],
|
||||
["minute", durationUnitMs.minute],
|
||||
["mins", durationUnitMs.minute],
|
||||
["min", durationUnitMs.minute],
|
||||
["m", durationUnitMs.minute],
|
||||
["hours", durationUnitMs.hour],
|
||||
["hour", durationUnitMs.hour],
|
||||
["hrs", durationUnitMs.hour],
|
||||
["hr", durationUnitMs.hour],
|
||||
["h", durationUnitMs.hour],
|
||||
["days", durationUnitMs.day],
|
||||
["day", durationUnitMs.day],
|
||||
["d", durationUnitMs.day],
|
||||
]);
|
||||
const MAX_SHORT_WINDOW_RETRY_AFTER_SECONDS = 60;
|
||||
|
||||
/** Extract guarded HTTP status evidence for retry and diagnostic consumers. */
|
||||
|
|
@ -83,16 +108,13 @@ function parseRetryAfterSeconds(valueText: string, nowMs: number): number | unde
|
|||
return undefined;
|
||||
}
|
||||
const unit = secondsMatch[2]?.toLowerCase();
|
||||
if (
|
||||
unit &&
|
||||
!/^(?:milliseconds?|msecs?|ms|seconds?|secs?|s|minutes?|mins?|m|hours?|hrs?|h|days?|d)$/.test(
|
||||
unit,
|
||||
)
|
||||
) {
|
||||
const unitMilliseconds = RETRY_AFTER_UNIT_MS.get(unit ?? "s");
|
||||
if (unitMilliseconds === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
const unitMilliseconds = milliseconds(`1${unit ?? "s"}` as Parameters<typeof milliseconds>[0]);
|
||||
return unitMilliseconds === 1 ? value / 1000 : value * (unitMilliseconds / 1000);
|
||||
return unitMilliseconds === durationUnitMs.millisecond
|
||||
? value / durationUnitMs.second
|
||||
: value * (unitMilliseconds / durationUnitMs.second);
|
||||
}
|
||||
const retryAtMs = parseRetryAfterHttpDateMs(valueText, nowMs);
|
||||
return retryAtMs === undefined ? undefined : Math.max(0, (retryAtMs - nowMs) / 1000);
|
||||
|
|
|
|||
|
|
@ -288,17 +288,67 @@ describe("parseDurationMs", () => {
|
|||
["supports decimals", "0.5s", 500],
|
||||
["parses composite hours+minutes", "1h30m", 5_400_000],
|
||||
["parses composite with milliseconds", "2m500ms", 120_500],
|
||||
["normalizes surrounding whitespace and case", " 1H30M ", 5_400_000],
|
||||
["accepts leading zeros", "0001.25s", 1250],
|
||||
["accepts zero", "0ms", 0],
|
||||
["rounds fractions of a millisecond", "0.5ms", 1],
|
||||
["rounds once after summing segments", "0.4ms0.4ms", 1],
|
||||
["allows repeated units in any order", "30m1h30m", 7_200_000],
|
||||
] as const)("%s", (_name, input, expected) => {
|
||||
expect(parseDurationMs(input)).toBe(expected);
|
||||
});
|
||||
|
||||
it("rejects invalid composite strings", () => {
|
||||
expect(() => parseDurationMs("1h30")).toThrow(/Invalid duration/);
|
||||
expect(() => parseDurationMs("1h-30m")).toThrow(/Invalid duration/);
|
||||
it.each([
|
||||
["ms", "1.5", 2],
|
||||
["s", "1.25", 1250],
|
||||
["m", "1.5", 90_000],
|
||||
["h", "0.25", 900_000],
|
||||
["d", "0.5", 43_200_000],
|
||||
] as const)("uses default unit %s only for bare numbers", (defaultUnit, input, expected) => {
|
||||
expect(parseDurationMs(input, { defaultUnit })).toBe(expected);
|
||||
expect(parseDurationMs("1s", { defaultUnit })).toBe(1000);
|
||||
});
|
||||
|
||||
it.each([
|
||||
"",
|
||||
" ",
|
||||
"-1s",
|
||||
"+1s",
|
||||
".5s",
|
||||
"1.s",
|
||||
"1e3s",
|
||||
"Infinity",
|
||||
"NaN",
|
||||
"1 seconds",
|
||||
"1sec",
|
||||
"1w",
|
||||
"1y",
|
||||
"1h 30m",
|
||||
"1h30",
|
||||
"1h-30m",
|
||||
])("rejects values outside the CLI duration grammar: %j", (input) => {
|
||||
expect(() => parseDurationMs(input)).toThrow(/Invalid duration/);
|
||||
});
|
||||
|
||||
it("retains the 100-character limit per token, including the default unit", () => {
|
||||
const value = `${"0".repeat(97)}1`;
|
||||
expect(parseDurationMs(`${value}ms`)).toBe(1);
|
||||
expect(parseDurationMs(value)).toBe(1);
|
||||
expect(parseDurationMs(`0${value}`, { defaultUnit: "s" })).toBe(1000);
|
||||
expect(parseDurationMs("1ms".repeat(40))).toBe(40);
|
||||
expect(() => parseDurationMs(`0${value}ms`)).toThrow(/Invalid duration/);
|
||||
expect(() => parseDurationMs(`0${value}`)).toThrow(/Invalid duration/);
|
||||
expect(() => parseDurationMs(`1s0${value}ms`)).toThrow(/Invalid duration/);
|
||||
});
|
||||
|
||||
it("keeps safe integer results exact", () => {
|
||||
expect(parseDurationMs("9007199254740991ms")).toBe(Number.MAX_SAFE_INTEGER);
|
||||
expect(parseDurationMs("9007199254740990ms1ms")).toBe(Number.MAX_SAFE_INTEGER);
|
||||
});
|
||||
|
||||
it("rejects unsafe millisecond results", () => {
|
||||
expect(() => parseDurationMs("9007199254740993ms")).toThrow(/Invalid duration/);
|
||||
expect(() => parseDurationMs("9007199254740990ms10ms")).toThrow(/Invalid duration/);
|
||||
expect(() => parseDurationMs("99999999999999999999d")).toThrow(/Invalid duration/);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,12 +1,20 @@
|
|||
// Duration parser shared by CLI flags, command directives, and config-backed timing values.
|
||||
import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/string-coerce";
|
||||
import milliseconds from "ms";
|
||||
import { durationUnitMs } from "../infra/format-time/duration-units.js";
|
||||
|
||||
/** Options for choosing the unit used by bare numeric duration values. */
|
||||
type DurationMsParseOptions = {
|
||||
defaultUnit?: "ms" | "s" | "m" | "h" | "d";
|
||||
};
|
||||
|
||||
const DURATION_UNIT_MS = new Map<string, number>([
|
||||
["ms", durationUnitMs.millisecond],
|
||||
["s", durationUnitMs.second],
|
||||
["m", durationUnitMs.minute],
|
||||
["h", durationUnitMs.hour],
|
||||
["d", durationUnitMs.day],
|
||||
]);
|
||||
|
||||
function invalidDuration(raw: string, reason?: string): Error {
|
||||
const value = raw.trim() ? `"${raw}"` : "empty value";
|
||||
const prefix = reason ? `Invalid duration (${reason}): ${value}.` : `Invalid duration: ${value}.`;
|
||||
|
|
@ -14,7 +22,11 @@ function invalidDuration(raw: string, reason?: string): Error {
|
|||
}
|
||||
|
||||
function parseDurationToken(raw: string, value: string, unit: string): number {
|
||||
const parsed = milliseconds(`${value}${unit}` as Parameters<typeof milliseconds>[0]);
|
||||
const multiplier = DURATION_UNIT_MS.get(unit);
|
||||
if (multiplier === undefined || value.length + unit.length > 100) {
|
||||
throw invalidDuration(raw);
|
||||
}
|
||||
const parsed = Number(value) * multiplier;
|
||||
if (!Number.isFinite(parsed) || parsed < 0) {
|
||||
throw invalidDuration(raw);
|
||||
}
|
||||
|
|
|
|||
9
src/infra/format-time/duration-units.ts
Normal file
9
src/infra/format-time/duration-units.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
export const durationUnitMs = {
|
||||
year: 31_536_000_000,
|
||||
week: 604_800_000,
|
||||
day: 86_400_000,
|
||||
hour: 3_600_000,
|
||||
minute: 60_000,
|
||||
second: 1_000,
|
||||
millisecond: 1,
|
||||
} as const;
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
import { durationUnitMs, type DurationPart } from "./format-duration-internal.js";
|
||||
import { durationUnitMs } from "./duration-units.ts";
|
||||
import type { DurationPart } from "./format-duration-internal.js";
|
||||
|
||||
// Exact display stays outside startup formatting; health uses weeks, cron uses days.
|
||||
export function resolveExactDurationParts(ms?: number | null, showWeeks = false) {
|
||||
|
|
|
|||
|
|
@ -1,14 +1,4 @@
|
|||
import prettyMilliseconds from "pretty-ms";
|
||||
|
||||
export const durationUnitMs = {
|
||||
year: 31_536_000_000,
|
||||
week: 604_800_000,
|
||||
day: 86_400_000,
|
||||
hour: 3_600_000,
|
||||
minute: 60_000,
|
||||
second: 1_000,
|
||||
millisecond: 1,
|
||||
} as const;
|
||||
import { durationUnitMs } from "./duration-units.ts";
|
||||
|
||||
export type DurationPart = { value: number | bigint; unit: keyof typeof durationUnitMs };
|
||||
|
||||
|
|
@ -23,21 +13,19 @@ function resolveDurationParts(ms: number, unitCount: number, showYears = false):
|
|||
// Large floats can retain a remainder after second-rounding; only subsecond input uses ms.
|
||||
{ value: ms < 1_000 ? Math.trunc(ms) : 0, unit: "millisecond" },
|
||||
];
|
||||
// pretty-ms counts nonzero units, so an empty middle bucket must not hide the next one.
|
||||
// Count nonzero units so an empty middle bucket does not hide the next one.
|
||||
const selected = parts.filter(({ value }) => value !== 0 && value !== 0n).slice(0, unitCount);
|
||||
return selected.length ? selected : [{ value: 0, unit: "millisecond" }];
|
||||
}
|
||||
|
||||
function formatDurationPart({ value, unit }: DurationPart, verbose = false): string {
|
||||
return verbose
|
||||
? `${value} ${unit}${value === 1 || value === 1n ? "" : "s"}`
|
||||
: `${value}${unit === "millisecond" ? "ms" : unit[0]}`;
|
||||
}
|
||||
|
||||
export function formatDurationParts(parts: DurationPart[], verbose = false): string {
|
||||
return parts
|
||||
.map(({ value, unit }) =>
|
||||
prettyMilliseconds(BigInt(value) * BigInt(durationUnitMs[unit]), {
|
||||
hideYear: unit !== "year",
|
||||
unitCount: 1,
|
||||
verbose,
|
||||
}),
|
||||
)
|
||||
.join(" ");
|
||||
return parts.map((part) => formatDurationPart(part, verbose)).join(" ");
|
||||
}
|
||||
|
||||
export function resolveCompactDurationParts(ms?: number | null, showYears = false) {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
// Duration formatting helpers produce compact, precise, and human display
|
||||
// strings from millisecond values.
|
||||
import prettyMilliseconds from "pretty-ms";
|
||||
import {
|
||||
formatDurationParts,
|
||||
formatSingleUnitDuration,
|
||||
|
|
@ -44,7 +43,7 @@ export function formatDurationPrecise(
|
|||
}
|
||||
const roundedMs = Math.max(0, Math.round(ms));
|
||||
if (roundedMs < 1000) {
|
||||
return prettyMilliseconds(roundedMs);
|
||||
return `${roundedMs}ms`;
|
||||
}
|
||||
return formatDurationSeconds(ms, {
|
||||
decimals: options.decimals ?? 2,
|
||||
|
|
|
|||
|
|
@ -8,7 +8,11 @@ import {
|
|||
resolveTimeZoneDayStartMs,
|
||||
resolveTimezone,
|
||||
} from "./format-datetime.js";
|
||||
import { formatSingleUnitDuration } from "./format-duration-internal.js";
|
||||
import {
|
||||
formatDurationParts,
|
||||
formatSingleUnitDuration,
|
||||
type DurationPart,
|
||||
} from "./format-duration-internal.js";
|
||||
import {
|
||||
formatDurationCompact,
|
||||
formatDurationHuman,
|
||||
|
|
@ -34,12 +38,16 @@ afterEach(() => {
|
|||
|
||||
describe("format-duration", () => {
|
||||
describe("formatDurationCompact", () => {
|
||||
it.each([null, undefined, 0, -100])("returns undefined for %j", (value) => {
|
||||
expect(formatDurationCompact(value)).toBeUndefined();
|
||||
});
|
||||
it.each([null, undefined, 0, -100, Number.NaN, Infinity, -Infinity])(
|
||||
"returns undefined for %j",
|
||||
(value) => {
|
||||
expect(formatDurationCompact(value)).toBeUndefined();
|
||||
},
|
||||
);
|
||||
|
||||
it("formats compact units and omits trailing zero components", () => {
|
||||
expectFormatterCases(formatDurationCompact, [
|
||||
{ input: 0.1, expected: "0ms" },
|
||||
{ input: 500, expected: "500ms" },
|
||||
{ input: 999, expected: "999ms" },
|
||||
{ input: 999.6, expected: "1s" },
|
||||
|
|
@ -56,6 +64,10 @@ describe("format-duration", () => {
|
|||
{ input: 86400000, expected: "1d" },
|
||||
{ input: 90000000, expected: "1d1h" },
|
||||
{ input: 172800000, expected: "2d" },
|
||||
{ input: 86_430_000, expected: "1d30s" },
|
||||
{ input: 3_599_500, expected: "1h" },
|
||||
{ input: 86_399_500, expected: "1d" },
|
||||
{ input: 366 * 86400000, expected: "366d" },
|
||||
]);
|
||||
});
|
||||
|
||||
|
|
@ -120,6 +132,10 @@ describe("format-duration", () => {
|
|||
});
|
||||
|
||||
it.each([
|
||||
[0, "0 milliseconds"],
|
||||
[1, "1 millisecond"],
|
||||
[2, "2 milliseconds"],
|
||||
[1_000, "1 second"],
|
||||
[30_000, "30 seconds"],
|
||||
[89_500, "1 minute"],
|
||||
[1_800_000, "30 minutes"],
|
||||
|
|
@ -131,6 +147,19 @@ describe("format-duration", () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe("formatDurationParts", () => {
|
||||
it("preserves bigint quantities without narrowing to numbers", () => {
|
||||
const parts: DurationPart[] = [
|
||||
{ value: 9_007_199_254_740_993n, unit: "day" },
|
||||
{ value: 1n, unit: "hour" },
|
||||
];
|
||||
expect(formatDurationParts(parts)).toBe("9007199254740993d 1h");
|
||||
expect(formatDurationParts(parts, true)).toBe("9007199254740993 days 1 hour");
|
||||
expect(formatDurationParts([{ value: 1n, unit: "year" }], true)).toBe("1 year");
|
||||
expect(formatDurationParts([{ value: 2n, unit: "year" }], true)).toBe("2 years");
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatDurationPrecise", () => {
|
||||
it.each([
|
||||
{ input: 500, expected: "500ms" },
|
||||
|
|
|
|||
|
|
@ -815,6 +815,9 @@ describe("resolveBuildAllSteps", () => {
|
|||
const cwd = fs.realpathSync(
|
||||
fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-source-rebuild-")),
|
||||
);
|
||||
// Artifact ownership must stop at this fixture, even inside another checkout.
|
||||
fs.writeFileSync(path.join(cwd, "package.json"), JSON.stringify({ name: "openclaw" }));
|
||||
fs.writeFileSync(path.join(cwd, "pnpm-workspace.yaml"), "packages: []\n");
|
||||
const childEnv = {
|
||||
OPENCLAW_BUILD_PRIVATE_QA: "1",
|
||||
OPENCLAW_RUN_NODE_SKIP_DTS_BUILD: skipDts,
|
||||
|
|
@ -878,6 +881,9 @@ describe("resolveBuildAllSteps", () => {
|
|||
]);
|
||||
expect(postbuild).not.toHaveBeenCalled();
|
||||
expect(fs.existsSync(path.join(cwd, ".artifacts/run-node-build.lock"))).toBe(false);
|
||||
expect(fs.existsSync(path.join(cwd, ".artifacts/dist-artifacts.lock/owner.json"))).toBe(
|
||||
false,
|
||||
);
|
||||
} finally {
|
||||
fs.rmSync(cwd, { recursive: true, force: true });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -47,9 +47,6 @@ it("compares real UI builds with canonical compression and keeps artifacts after
|
|||
]) {
|
||||
fs.mkdirSync(path.join(root, directory), { recursive: true });
|
||||
}
|
||||
for (const directory of ["node_modules", "ui/node_modules"]) {
|
||||
fs.symlinkSync(path.join(repoRoot, directory), path.join(root, directory), "junction");
|
||||
}
|
||||
for (const script of [
|
||||
"check-control-ui-performance-base.mts",
|
||||
"check-control-ui-performance.mts",
|
||||
|
|
@ -59,18 +56,32 @@ it("compares real UI builds with canonical compression and keeps artifacts after
|
|||
"lib/control-ui-i18n-config.ts",
|
||||
"lib/repo-root.mjs",
|
||||
"lib/output-root-guard.mjs",
|
||||
"lib/record-shared.mjs",
|
||||
]) {
|
||||
fs.copyFileSync(path.join(repoRoot, "scripts", script), path.join(root, "scripts", script));
|
||||
}
|
||||
write("scripts/tsx.mjs", `await import(${JSON.stringify(tsxImport)});\n`);
|
||||
write(".gitignore", "node_modules\ndist/\n");
|
||||
write("package.json", '{"name":"ui-budget-proof","version":"1.0.0","type":"module"}');
|
||||
write(
|
||||
"package.json",
|
||||
JSON.stringify({
|
||||
name: "ui-budget-proof",
|
||||
version: "1.0.0",
|
||||
type: "module",
|
||||
dependencies: { "base-only-library": "file:vendor/base-only-library" },
|
||||
}),
|
||||
);
|
||||
write(
|
||||
"vendor/base-only-library/package.json",
|
||||
'{"name":"base-only-library","version":"1.0.0","type":"module","exports":"./index.js"}',
|
||||
);
|
||||
write("vendor/base-only-library/index.js", 'export const value = "base dependency";');
|
||||
write("pnpm-workspace.yaml", 'packages: ["ui", "packages/*"]\n');
|
||||
write("ui/package.json", '{"name":"ui-budget-proof-ui","type":"module"}');
|
||||
write("ui/index.html", '<script type="module" src="/main.js"></script>');
|
||||
write(
|
||||
"ui/main.js",
|
||||
'import "./style.css"; import { message } from "../packages/styles/main.js"; document.body.textContent = message;',
|
||||
'import "./style.css"; import { message } from "../packages/styles/main.js"; import { value } from "base-only-library"; document.body.textContent = message + "/" + value;',
|
||||
);
|
||||
write(
|
||||
"packages/styles/main.js",
|
||||
|
|
@ -137,11 +148,26 @@ export default {
|
|||
`;
|
||||
write("ui/vite.config.ts", config);
|
||||
write("ui/style.css", css(1_000));
|
||||
const lockfile = spawnSync(
|
||||
"pnpm",
|
||||
["install", "--lockfile-only", "--no-frozen-lockfile", "--ignore-scripts", "--offline"],
|
||||
{ cwd: root, encoding: "utf8", timeout: 30_000 },
|
||||
);
|
||||
expect(lockfile.status, `${lockfile.stdout}${lockfile.stderr}`).toBe(0);
|
||||
git("init", "--quiet");
|
||||
git("add", ".");
|
||||
git("commit", "--quiet", "-m", "base");
|
||||
const base = git("rev-parse", "HEAD");
|
||||
for (const directory of ["node_modules", "ui/node_modules"]) {
|
||||
const target = path.join(root, directory);
|
||||
fs.rmSync(target, { recursive: true, force: true });
|
||||
fs.symlinkSync(path.join(repoRoot, directory), target, "junction");
|
||||
}
|
||||
write("package.json", '{"name":"ui-budget-proof","version":"1.0.1","type":"module"}');
|
||||
write(
|
||||
"ui/main.js",
|
||||
'import "./style.css"; import { message } from "../packages/styles/main.js"; document.body.textContent = message;',
|
||||
);
|
||||
write("ui/vite.config.ts", config.replace("level: 0", "level: 9"));
|
||||
write("packages/workspace-value/index.js", 'export const message = "candidate workspace";');
|
||||
|
||||
|
|
@ -247,7 +273,10 @@ export default {
|
|||
runInNewContext(entryCode, { document });
|
||||
return document.body.textContent;
|
||||
}),
|
||||
).toEqual(["candidate workspace", "base workspace"]);
|
||||
).toEqual(["candidate workspace", "base workspace/base dependency"]);
|
||||
expect(fs.realpathSync(path.join(root, "node_modules"))).toBe(
|
||||
fs.realpathSync(path.join(repoRoot, "node_modules")),
|
||||
);
|
||||
expect(fs.existsSync(path.join(root, "dist/control-ui/index.html"))).toBe(true);
|
||||
expect(
|
||||
fs.readdirSync(scratch).filter((name) => name.startsWith("openclaw-ui-performance-base-")),
|
||||
|
|
|
|||
41
test/scripts/format-duration.test.ts
Normal file
41
test/scripts/format-duration.test.ts
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { formatDurationElapsed } from "../../scripts/lib/format-duration.mts";
|
||||
|
||||
describe("formatDurationElapsed", () => {
|
||||
it.each<[number, 1 | 2, string]>([
|
||||
[0, 1, "0ms"],
|
||||
[999, 1, "999ms"],
|
||||
[1_000, 1, "1s"],
|
||||
[1_100, 2, "1.10s"],
|
||||
[1_230, 2, "1.23s"],
|
||||
[3_100, 1, "3.1s"],
|
||||
[10_000, 2, "10s"],
|
||||
[12_300, 1, "12.3s"],
|
||||
[59_900, 1, "59.9s"],
|
||||
[60_000, 1, "1m"],
|
||||
[60_100, 1, "1m 0.1s"],
|
||||
[3_661_100, 1, "1h 1m 1.1s"],
|
||||
[86_400_100, 1, "1d 0.1s"],
|
||||
[31_626_061_100, 1, "1y 1d 1h 1m 1.1s"],
|
||||
])("formats %dms with %d second decimals", (input, secondsDecimalDigits, expected) => {
|
||||
expect(formatDurationElapsed(input, { secondsDecimalDigits })).toBe(expected);
|
||||
});
|
||||
|
||||
it("folds years into days for smoke timings and skips empty units", () => {
|
||||
expect(formatDurationElapsed(31_622_401_000, { showYears: false, unitCount: 2 })).toBe(
|
||||
"366d 1s",
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[0, "0ms"],
|
||||
[999, "999ms"],
|
||||
[1_100, "1.1s"],
|
||||
[59_900, "59.9s"],
|
||||
[60_100, "1m"],
|
||||
[3_660_000, "1h"],
|
||||
[31_622_400_000, "1y"],
|
||||
])("keeps only the first nonzero timing unit for %dms", (input, expected) => {
|
||||
expect(formatDurationElapsed(input, { unitCount: 1 })).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
|
@ -51,7 +51,6 @@ export function linkPrWrapperDependencies(destination: string): void {
|
|||
"koffi",
|
||||
"kysely",
|
||||
"minimatch",
|
||||
"ms",
|
||||
"p-map",
|
||||
"semver",
|
||||
"string-width",
|
||||
|
|
|
|||
|
|
@ -149,7 +149,6 @@ export function createFixture(groups: readonly string[], root: string) {
|
|||
"tsx",
|
||||
"esbuild",
|
||||
"import-meta-resolve",
|
||||
...(groups === TSDOWN_NON_SDK_DTS_CONFIG_GROUPS ? ["pretty-ms"] : []),
|
||||
]) {
|
||||
const target = path.join(root, "node_modules", name);
|
||||
fs.mkdirSync(path.dirname(target), { recursive: true });
|
||||
|
|
@ -205,6 +204,9 @@ export function createFixture(groups: readonly string[], root: string) {
|
|||
"src/infra/update-managed-service-handoff-native-loader.ts",
|
||||
"src/shared/deferred.ts",
|
||||
"src/shared/freebsd-process-identity.ts",
|
||||
"src/infra/format-time/duration-units.ts",
|
||||
"src/infra/format-time/format-duration-exact.ts",
|
||||
"src/infra/format-time/format-duration-internal.ts",
|
||||
"src/infra/node-runtime-executable.ts",
|
||||
"src/infra/runtime-dependency-ownership.ts",
|
||||
"src/shared/non-packaged-plugin-dirs.ts",
|
||||
|
|
|
|||
|
|
@ -14,7 +14,6 @@
|
|||
"../../ui/src/css.d.ts",
|
||||
"../../ui/src/i18n/virtual-locale.d.ts",
|
||||
"../../ui/src/markdown-it-emoji.d.ts",
|
||||
"../../ui/src/markdown-it-task-lists.d.ts",
|
||||
"../../ui/src/types/highlight-js-subpaths.d.ts",
|
||||
"../../ui/src/types/novnc.d.ts"
|
||||
],
|
||||
|
|
|
|||
|
|
@ -185,7 +185,6 @@
|
|||
"node_modules/lit/static-html.js",
|
||||
"node_modules/markdown-it-cjk-friendly/dist/index.js",
|
||||
"node_modules/markdown-it-emoji/lib/data/full.mjs",
|
||||
"node_modules/markdown-it-task-lists/index.js",
|
||||
"node_modules/markdown-it/dist/markdown-it.mjs",
|
||||
"node_modules/mdast-util-from-markdown/lib/index.js",
|
||||
"node_modules/mdast-util-gfm-table/lib/index.js",
|
||||
|
|
@ -245,11 +244,8 @@
|
|||
"node_modules/micromark/lib/parse.js",
|
||||
"node_modules/micromark/lib/postprocess.js",
|
||||
"node_modules/micromark/lib/preprocess.js",
|
||||
"node_modules/ms/index.js",
|
||||
"node_modules/nanoid/index.browser.js",
|
||||
"node_modules/nanoid/url-alphabet/index.js",
|
||||
"node_modules/parse-ms/index.js",
|
||||
"node_modules/pretty-ms/index.js",
|
||||
"node_modules/punycode.js/punycode.es6.js",
|
||||
"node_modules/remend/dist/index.js",
|
||||
"node_modules/typebox/build/compile/code.mjs",
|
||||
|
|
@ -1064,6 +1060,7 @@
|
|||
"src/gateway/events.ts",
|
||||
"src/infra/approval-display-paths.ts",
|
||||
"src/infra/exec-approvals-core.ts",
|
||||
"src/infra/format-time/duration-units.ts",
|
||||
"src/infra/format-time/format-duration-internal.ts",
|
||||
"src/infra/format-time/format-duration.ts",
|
||||
"src/infra/map-size.ts",
|
||||
|
|
|
|||
|
|
@ -96,7 +96,6 @@ export function controlUiStableChunkName(id: string): string | undefined {
|
|||
if (
|
||||
moduleIdIncludesPackage(id, "highlight.js") ||
|
||||
moduleIdIncludesPackage(id, "markdown-it") ||
|
||||
moduleIdIncludesPackage(id, "markdown-it-task-lists") ||
|
||||
moduleIdIncludesPackage(id, "dompurify") ||
|
||||
moduleIdIncludesPackage(id, "entities") ||
|
||||
moduleIdIncludesPackage(id, "linkify-it") ||
|
||||
|
|
|
|||
|
|
@ -48,7 +48,6 @@
|
|||
"markdown-it": "15.0.2",
|
||||
"markdown-it-cjk-friendly": "3.0.0",
|
||||
"markdown-it-emoji": "3.1.0",
|
||||
"markdown-it-task-lists": "2.1.1",
|
||||
"parse5": "8.0.1",
|
||||
"remend": "1.3.1",
|
||||
"zod": "4.6.5"
|
||||
|
|
|
|||
|
|
@ -62,7 +62,7 @@ export function installAssistantTranscriptRoleMarkdown(
|
|||
escapeHtml: (value: string) => string,
|
||||
): void {
|
||||
md.use(markdownItAssistantTranscriptRoles, {
|
||||
// The task-list plugin injects a trusted checkbox HTML token. It is visible
|
||||
// The task-list rule injects a trusted checkbox HTML token. It is visible
|
||||
// UI structure, not text before the list item's semantic first character.
|
||||
isStructuralHtmlInline: (token) => token.meta?.taskListPlugin === true,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
import MarkdownIt, { type MarkdownIt as MarkdownItParser, type Token } from "markdown-it";
|
||||
import markdownItCjkFriendly from "markdown-it-cjk-friendly";
|
||||
import markdownItTaskLists from "markdown-it-task-lists";
|
||||
import { t } from "../i18n/index.ts";
|
||||
import { fileKindForPath, shortestFileLabels } from "./file-kind.ts";
|
||||
import { isGitHubHost } from "./github-link-eligibility.ts";
|
||||
|
|
@ -595,32 +594,44 @@ export function createMarkdownParser(): MarkdownItParser {
|
|||
|
||||
installMarkdownGitHubRefs(markdownParser);
|
||||
|
||||
// Enable GFM task list checkboxes (- [x] / - [ ]).
|
||||
// enabled: false keeps checkboxes read-only (disabled="") — task lists in
|
||||
// chat messages are display-only, not interactive forms.
|
||||
// label: false avoids wrapping item text in <label>, which would break
|
||||
// accessibility when the item contains links (MDN warns against anchors inside labels).
|
||||
markdownParser.use(markdownItTaskLists, { enabled: false, label: false });
|
||||
|
||||
// The plugin inserts its checkbox as the first inline child. Trust only that
|
||||
// generated token so later user-authored HTML remains escaped.
|
||||
markdownParser.core.ruler.after("github-task-lists", "task-list-allowlist", (state) => {
|
||||
for (const [index, listItem] of state.tokens.entries()) {
|
||||
if (listItem.type !== "list_item_open" || listItem.attrGet("class") !== "task-list-item") {
|
||||
markdownParser.core.ruler.after("inline", "task-lists", (state) => {
|
||||
for (const [index, inline] of state.tokens.entries()) {
|
||||
const listItem = state.tokens[index - 2];
|
||||
const children = inline.children;
|
||||
const firstChild = children?.[0];
|
||||
if (
|
||||
inline.type !== "inline" ||
|
||||
state.tokens[index - 1]?.type !== "paragraph_open" ||
|
||||
listItem?.type !== "list_item_open" ||
|
||||
!/^\[[ xX]\] /.test(inline.content) ||
|
||||
!children ||
|
||||
!firstChild
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const checkbox = state.tokens[index + 2]?.children?.[0];
|
||||
if (checkbox?.type === "html_inline") {
|
||||
checkbox.meta = { taskListPlugin: true };
|
||||
// Task lists are display-only; labels would also wrap any links in the item.
|
||||
const checkbox = new state.Token("html_inline", "", 0);
|
||||
const checked = inline.content[1] !== " " ? ' checked=""' : "";
|
||||
checkbox.content = `<input class="task-list-item-checkbox"${checked} disabled="" type="checkbox">`;
|
||||
// Trust only the generated checkbox, including for transcript-role projection.
|
||||
checkbox.meta = { taskListPlugin: true };
|
||||
firstChild.content = firstChild.content.slice(3);
|
||||
children.unshift(checkbox);
|
||||
inline.content = inline.content.slice(3);
|
||||
listItem.attrSet("class", "task-list-item");
|
||||
for (let parent = index - 3; parent >= 0; parent--) {
|
||||
const token = state.tokens[parent];
|
||||
if (token?.level === listItem.level - 1) {
|
||||
token.attrSet("class", "contains-task-list");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Override html_block and html_inline to escape raw HTML (#13937). Progress-card
|
||||
// rendering strips non-progress HTML instead of exposing escaped tag text.
|
||||
// Exception: html_inline tokens marked by a trusted plugin (meta.taskListPlugin)
|
||||
// are allowed through — they are generated by our own plugin pipeline, not user input,
|
||||
// and DOMPurify provides the final safety net regardless.
|
||||
// Only generated task-list checkboxes bypass escaping; DOMPurify still sanitizes them.
|
||||
// Renderer rules degrade to empty output on impossible token misses instead of
|
||||
// throwing mid-render; markdown input is untrusted and the chat view must not crash.
|
||||
markdownParser.renderer.rules.html_block = (tokens, index, _options, env) =>
|
||||
|
|
|
|||
|
|
@ -63,16 +63,77 @@ describe("toSanitizedMarkdownHtml", () => {
|
|||
);
|
||||
});
|
||||
|
||||
it("marks a role header after the structural task-list checkbox", () => {
|
||||
const fragment = htmlFragment(
|
||||
toSanitizedMarkdownHtml("- [ ] user[Thu 2026-07-02] authorize", {
|
||||
assistantTranscriptRoleHeaders: true,
|
||||
}),
|
||||
it("keeps mixed nested and ordered lists on their own containers", () => {
|
||||
const html = toSanitizedMarkdownHtml(
|
||||
[
|
||||
"3. [X] Parent",
|
||||
" - ordinary",
|
||||
" - [ ] Nested",
|
||||
" - plain",
|
||||
"4. [ ] Sibling",
|
||||
].join("\n"),
|
||||
);
|
||||
expect(html).toBe(
|
||||
[
|
||||
'<ol start="3" class="contains-task-list">',
|
||||
'<li class="task-list-item"><input class="task-list-item-checkbox" checked="" disabled="" type="checkbox"> Parent',
|
||||
"<ul>",
|
||||
"<li>ordinary",
|
||||
'<ul class="contains-task-list">',
|
||||
'<li class="task-list-item"><input class="task-list-item-checkbox" disabled="" type="checkbox"> Nested</li>',
|
||||
"<li>plain</li>",
|
||||
"</ul>",
|
||||
"</li>",
|
||||
"</ul>",
|
||||
"</li>",
|
||||
'<li class="task-list-item"><input class="task-list-item-checkbox" disabled="" type="checkbox"> Sibling</li>',
|
||||
"</ol>",
|
||||
"",
|
||||
].join("\n"),
|
||||
);
|
||||
});
|
||||
|
||||
expect(fragment.querySelector('input[type="checkbox"]')).not.toBeNull();
|
||||
expect(fragment.querySelector("code.assistant-transcript-role")?.textContent).toBe(
|
||||
"user[Thu 2026-07-02]",
|
||||
it("preserves loose paragraphs, line breaks, formatting, and marker spacing", () => {
|
||||
const html = toSanitizedMarkdownHtml(
|
||||
"- [ ] first\n continued\n\n [x] Later paragraph\n\n- [X] **Done**",
|
||||
);
|
||||
expect(html).toBe(
|
||||
[
|
||||
'<ul class="contains-task-list">',
|
||||
'<li class="task-list-item">',
|
||||
'<p><input class="task-list-item-checkbox" disabled="" type="checkbox"> first<br>',
|
||||
"continued</p>",
|
||||
"<p>[x] Later paragraph</p>",
|
||||
"</li>",
|
||||
'<li class="task-list-item">',
|
||||
'<p><input class="task-list-item-checkbox" checked="" disabled="" type="checkbox"> <strong>Done</strong></p>',
|
||||
"</li>",
|
||||
"</ul>",
|
||||
"",
|
||||
].join("\n"),
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["- [x]done", "<ul>\n<li>[x]done</li>\n</ul>\n"],
|
||||
["- [x]\tdone", "<ul>\n<li>[x]\tdone</li>\n</ul>\n"],
|
||||
["- [x]", "<ul>\n<li>[x]</li>\n</ul>\n"],
|
||||
["- [x]\n continued", "<ul>\n<li>[x]<br>\ncontinued</li>\n</ul>\n"],
|
||||
["- [-] custom", "<ul>\n<li>[-] custom</li>\n</ul>\n"],
|
||||
["- [y] custom", "<ul>\n<li>[y] custom</li>\n</ul>\n"],
|
||||
["- \\[x] escaped", "<ul>\n<li>[x] escaped</li>\n</ul>\n"],
|
||||
["- `[x]` code", "<ul>\n<li><code>[x]</code> code</li>\n</ul>\n"],
|
||||
["[ ] paragraph", "<p>[ ] paragraph</p>\n"],
|
||||
])("leaves non-task markers unchanged: %s", (markdown, expected) => {
|
||||
expect(toSanitizedMarkdownHtml(markdown)).toBe(expected);
|
||||
});
|
||||
|
||||
it("marks a role header after the structural task-list checkbox", () => {
|
||||
const html = toSanitizedMarkdownHtml("- [ ] user[Thu 2026-07-02] authorize", {
|
||||
assistantTranscriptRoleHeaders: true,
|
||||
});
|
||||
expect(html).toBe(
|
||||
'<ul class="contains-task-list">\n<li class="task-list-item"><input class="task-list-item-checkbox" disabled="" type="checkbox"> <code class="assistant-transcript-role">user[Thu 2026-07-02]</code> authorize</li>\n</ul>\n',
|
||||
);
|
||||
});
|
||||
|
||||
|
|
@ -83,6 +144,15 @@ describe("toSanitizedMarkdownHtml", () => {
|
|||
);
|
||||
});
|
||||
|
||||
it("preserves link classes without trusting authored checkbox HTML", () => {
|
||||
const html = toSanitizedMarkdownHtml(
|
||||
'- [x] <input class="task-list-item-checkbox" type="checkbox" checked> [PR](https://github.com/openclaw/openclaw/pull/123) [unsafe](javascript:alert(1))',
|
||||
);
|
||||
expect(html).toBe(
|
||||
'<ul class="contains-task-list">\n<li class="task-list-item"><input class="task-list-item-checkbox" checked="" disabled="" type="checkbox"> <input class="task-list-item-checkbox" type="checkbox" checked> <a href="https://github.com/openclaw/openclaw/pull/123" class="markdown-github-link" rel="noreferrer noopener" target="_blank">PR</a> unsafe</li>\n</ul>\n',
|
||||
);
|
||||
});
|
||||
|
||||
it("escapes HTML injection in task items", () => {
|
||||
const html = toSanitizedMarkdownHtml("- [ ] <script>alert(1)</script>");
|
||||
expect(html).toBe(
|
||||
|
|
|
|||
11
ui/src/markdown-it-task-lists.d.ts
vendored
11
ui/src/markdown-it-task-lists.d.ts
vendored
|
|
@ -1,11 +0,0 @@
|
|||
// Control UI type declarations define markdown it task lists contracts.
|
||||
declare module "markdown-it-task-lists" {
|
||||
import type { MarkdownIt } from "markdown-it";
|
||||
interface TaskListsOptions {
|
||||
enabled?: boolean;
|
||||
label?: boolean;
|
||||
labelAfter?: boolean;
|
||||
}
|
||||
const plugin: (md: MarkdownIt, options?: TaskListsOptions) => void;
|
||||
export default plugin;
|
||||
}
|
||||
|
|
@ -682,12 +682,7 @@ export async function startControlUiE2eServer(
|
|||
},
|
||||
logLevel: "error",
|
||||
optimizeDeps: {
|
||||
include: [
|
||||
"ipaddr.js",
|
||||
"lit/directives/repeat.js",
|
||||
"markdown-it-task-lists",
|
||||
...commonJsOptimizeDeps,
|
||||
],
|
||||
include: ["ipaddr.js", "lit/directives/repeat.js", ...commonJsOptimizeDeps],
|
||||
},
|
||||
publicDir: path.join(uiRoot, "public"),
|
||||
plugins: [controlUiLocaleModulesPlugin(), controlUiBrowserOnlySharedModuleAliases()],
|
||||
|
|
|
|||
|
|
@ -635,12 +635,7 @@ export default function controlUiViteConfig(
|
|||
},
|
||||
},
|
||||
optimizeDeps: {
|
||||
include: [
|
||||
"ipaddr.js",
|
||||
"lit/directives/repeat.js",
|
||||
"markdown-it-task-lists",
|
||||
...commonJsOptimizeDeps,
|
||||
],
|
||||
include: ["ipaddr.js", "lit/directives/repeat.js", ...commonJsOptimizeDeps],
|
||||
},
|
||||
resolve: {
|
||||
alias: [
|
||||
|
|
|
|||
|
|
@ -261,11 +261,9 @@ export function createUiBrowserVitestConfig(env = process.env): ViteUserConfig {
|
|||
"lit/directives/unsafe-html.js",
|
||||
"lit/static-html.js",
|
||||
"markdown-it",
|
||||
"markdown-it-task-lists",
|
||||
"mdast-util-from-markdown",
|
||||
"mdast-util-gfm-table",
|
||||
"micromark-extension-gfm-table",
|
||||
"pretty-ms",
|
||||
"remend",
|
||||
"typebox/compile",
|
||||
"typebox/guard",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue