feat(line): add outbound media support for image, video, and audio

pnpm install --frozen-lockfile
pnpm build
pnpm check
pnpm vitest run extensions/line/src/channel.sendPayload.test.ts extensions/line/src/send.test.ts extensions/line/src/outbound-media.test.ts

Co-authored-by: masatohoshino <246810661+masatohoshino@users.noreply.github.com>
Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
This commit is contained in:
Masato Hoshino 2026-03-29 10:51:16 +09:00 committed by GitHub
parent f93ccc3443
commit 9449e54f4f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 727 additions and 19 deletions

View File

@ -4,6 +4,10 @@ Docs: https://docs.openclaw.ai
## Unreleased
### Changes
- LINE/outbound media: add LINE image, video, and audio outbound sends on the LINE-specific delivery path, including explicit preview/tracking handling for videos while keeping generic media sends on the existing image-only route. (#45826) Thanks @masatohoshino.
### Fixes
- macOS/local gateway: stop OpenClaw.app from killing healthy local gateway listeners after startup by recognizing the current `openclaw-gateway` process title and using the current `openclaw gateway` launch shape.

View File

@ -258,12 +258,16 @@ describe("linePlugin outbound.sendPayload", () => {
cfg,
});
expect(mocks.sendMessageLine).toHaveBeenCalledWith("line:user:3", "", {
verbose: false,
mediaUrl: "https://example.com/img.jpg",
accountId: "default",
cfg,
});
expect(mocks.sendMessageLine).toHaveBeenCalledWith(
"line:user:3",
"",
expect.objectContaining({
verbose: false,
mediaUrl: "https://example.com/img.jpg",
accountId: "default",
cfg,
}),
);
expect(mocks.pushTextMessageWithQuickReplies).toHaveBeenCalledWith(
"line:user:3",
"Hello",
@ -275,6 +279,63 @@ describe("linePlugin outbound.sendPayload", () => {
expect(mediaOrder).toBeLessThan(quickReplyOrder);
});
it("keeps generic media payloads on the image-only send path", async () => {
const { runtime, mocks } = createRuntime();
setLineRuntime(runtime);
const cfg = { channels: { line: {} } } as OpenClawConfig;
await linePlugin.outbound!.sendPayload!({
to: "line:user:4",
text: "",
payload: {
mediaUrl: "https://example.com/video.mp4",
},
accountId: "default",
cfg,
});
expect(mocks.sendMessageLine).toHaveBeenCalledWith("line:user:4", "", {
verbose: false,
mediaUrl: "https://example.com/video.mp4",
accountId: "default",
cfg,
});
});
it("uses LINE-specific media options for rich media payloads", async () => {
const { runtime, mocks } = createRuntime();
setLineRuntime(runtime);
const cfg = { channels: { line: {} } } as OpenClawConfig;
await linePlugin.outbound!.sendPayload!({
to: "line:user:5",
text: "",
payload: {
mediaUrl: "https://example.com/video.mp4",
channelData: {
line: {
mediaKind: "video",
previewImageUrl: "https://example.com/preview.jpg",
trackingId: "track-123",
},
},
},
accountId: "default",
cfg,
});
expect(mocks.sendMessageLine).toHaveBeenCalledWith("line:user:5", "", {
verbose: false,
mediaUrl: "https://example.com/video.mp4",
mediaKind: "video",
previewImageUrl: "https://example.com/preview.jpg",
durationMs: undefined,
trackingId: "track-123",
accountId: "default",
cfg,
});
});
it("uses configured text chunk limit for payloads", async () => {
const { runtime, mocks } = createRuntime();
setLineRuntime(runtime);
@ -305,6 +366,114 @@ describe("linePlugin outbound.sendPayload", () => {
});
expect(mocks.chunkMarkdownText).toHaveBeenCalledWith("Hello world", 123);
});
it("omits trackingId for non-user quick-reply inline video media", async () => {
const { runtime, mocks } = createRuntime();
setLineRuntime(runtime);
const cfg = { channels: { line: {} } } as OpenClawConfig;
const payload = {
text: "",
mediaUrl: "https://example.com/video.mp4",
channelData: {
line: {
quickReplies: ["One"],
mediaKind: "video" as const,
previewImageUrl: "https://example.com/preview.jpg",
trackingId: "track-group",
},
},
};
await linePlugin.outbound!.sendPayload!({
to: "line:group:C123",
text: payload.text,
payload,
accountId: "default",
cfg,
});
expect(mocks.pushMessagesLine).toHaveBeenCalledWith(
"line:group:C123",
[
{
type: "video",
originalContentUrl: "https://example.com/video.mp4",
previewImageUrl: "https://example.com/preview.jpg",
quickReply: { items: ["One"] },
},
],
{ verbose: false, accountId: "default", cfg },
);
});
it("keeps trackingId for user quick-reply inline video media", async () => {
const { runtime, mocks } = createRuntime();
setLineRuntime(runtime);
const cfg = { channels: { line: {} } } as OpenClawConfig;
const payload = {
text: "",
mediaUrl: "https://example.com/video.mp4",
channelData: {
line: {
quickReplies: ["One"],
mediaKind: "video" as const,
previewImageUrl: "https://example.com/preview.jpg",
trackingId: "track-user",
},
},
};
await linePlugin.outbound!.sendPayload!({
to: "line:user:U123",
text: payload.text,
payload,
accountId: "default",
cfg,
});
expect(mocks.pushMessagesLine).toHaveBeenCalledWith(
"line:user:U123",
[
{
type: "video",
originalContentUrl: "https://example.com/video.mp4",
previewImageUrl: "https://example.com/preview.jpg",
trackingId: "track-user",
quickReply: { items: ["One"] },
},
],
{ verbose: false, accountId: "default", cfg },
);
});
it("rejects quick-reply inline video media without previewImageUrl", async () => {
const { runtime } = createRuntime();
setLineRuntime(runtime);
const cfg = { channels: { line: {} } } as OpenClawConfig;
const payload = {
text: "",
mediaUrl: "https://example.com/video.mp4",
channelData: {
line: {
quickReplies: ["One"],
mediaKind: "video" as const,
},
},
};
await expect(
linePlugin.outbound!.sendPayload!({
to: "line:user:U123",
text: payload.text,
payload,
accountId: "default",
cfg,
}),
).rejects.toThrow(/require previewimageurl/i);
});
});
describe("linePlugin config.formatAllowFrom", () => {

View File

@ -49,9 +49,6 @@ export const linePlugin: ChannelPlugin<ResolvedLineAccount> = createChatChannelP
if (!trimmed) {
return false;
}
// LINE user IDs are typically U followed by 32 hex characters
// Group IDs are C followed by 32 hex characters
// Room IDs are R followed by 32 hex characters
return /^[UCR][a-f0-9]{32}$/i.test(trimmed) || /^line:/i.test(trimmed);
},
hint: "<userId|groupId|roomId>",
@ -115,7 +112,6 @@ export const linePlugin: ChannelPlugin<ResolvedLineAccount> = createChatChannelP
text: {
idLabel: "lineUserId",
message: "OpenClaw: your access has been approved.",
// LINE IDs are case-sensitive; only strip prefix variants (line: / line:user:).
normalizeAllowEntry: createPairingPrefixStripper(/^line:(?:user:)?/i),
notify: async ({ cfg, id, message }) => {
const line = getLineRuntime().channel.line;

View File

@ -0,0 +1,137 @@
import { describe, expect, it } from "vitest";
import { detectLineMediaKind, resolveLineOutboundMedia, validateLineMediaUrl } from "./outbound-media.js";
describe("validateLineMediaUrl", () => {
it("accepts HTTPS URL", () => {
expect(() => validateLineMediaUrl("https://example.com/image.jpg")).not.toThrow();
});
it("accepts uppercase HTTPS scheme", () => {
expect(() => validateLineMediaUrl("HTTPS://EXAMPLE.COM/img.jpg")).not.toThrow();
});
it("rejects HTTP URL", () => {
expect(() => validateLineMediaUrl("http://example.com/image.jpg")).toThrow(/must use HTTPS/i);
});
it("rejects URL longer than 2000 chars", () => {
const longUrl = `https://example.com/${"a".repeat(1981)}`;
expect(longUrl.length).toBeGreaterThan(2000);
expect(() => validateLineMediaUrl(longUrl)).toThrow(/2000 chars or less/i);
});
});
describe("detectLineMediaKind", () => {
it("maps image MIME to image", () => {
expect(detectLineMediaKind("image/jpeg")).toBe("image");
});
it("maps uppercase image MIME to image", () => {
expect(detectLineMediaKind("IMAGE/JPEG")).toBe("image");
});
it("maps video MIME to video", () => {
expect(detectLineMediaKind("video/mp4")).toBe("video");
});
it("maps audio MIME to audio", () => {
expect(detectLineMediaKind("audio/mpeg")).toBe("audio");
});
it("falls back unknown MIME to image", () => {
expect(detectLineMediaKind("application/octet-stream")).toBe("image");
});
});
describe("resolveLineOutboundMedia", () => {
it("respects explicit media kind without remote MIME probing", async () => {
await expect(
resolveLineOutboundMedia("https://example.com/download?id=123", { mediaKind: "video" }),
).resolves.toEqual({
mediaUrl: "https://example.com/download?id=123",
mediaKind: "video",
});
});
it("preserves explicit video kind when a preview URL is provided", async () => {
await expect(
resolveLineOutboundMedia("https://example.com/download?id=123", {
mediaKind: "video",
previewImageUrl: "https://example.com/preview.jpg",
}),
).resolves.toEqual({
mediaUrl: "https://example.com/download?id=123",
mediaKind: "video",
previewImageUrl: "https://example.com/preview.jpg",
});
});
it("infers audio kind from explicit duration metadata when mediaKind is omitted", async () => {
await expect(
resolveLineOutboundMedia("https://example.com/download?id=audio", {
durationMs: 60000,
}),
).resolves.toEqual({
mediaUrl: "https://example.com/download?id=audio",
mediaKind: "audio",
durationMs: 60000,
});
});
it("does not infer video from previewImageUrl alone", async () => {
await expect(
resolveLineOutboundMedia("https://example.com/image.jpg", {
previewImageUrl: "https://example.com/preview.jpg",
}),
).resolves.toEqual({
mediaUrl: "https://example.com/image.jpg",
mediaKind: "image",
previewImageUrl: "https://example.com/preview.jpg",
});
});
it("infers media kinds from known HTTPS file extensions", async () => {
await expect(resolveLineOutboundMedia("https://example.com/audio.mp3")).resolves.toEqual({
mediaUrl: "https://example.com/audio.mp3",
mediaKind: "audio",
});
await expect(resolveLineOutboundMedia("https://example.com/video.mp4")).resolves.toEqual({
mediaUrl: "https://example.com/video.mp4",
mediaKind: "video",
});
await expect(resolveLineOutboundMedia("https://example.com/image.jpg")).resolves.toEqual({
mediaUrl: "https://example.com/image.jpg",
mediaKind: "image",
});
});
it("validates previewImageUrl when provided", async () => {
await expect(
resolveLineOutboundMedia("https://example.com/video.mp4", {
mediaKind: "video",
previewImageUrl: "http://example.com/preview.jpg",
}),
).rejects.toThrow(/must use HTTPS/i);
});
it("falls back to image when no explicit LINE media options or known extension are present", async () => {
await expect(
resolveLineOutboundMedia("https://example.com/download?id=audio"),
).resolves.toEqual({
mediaUrl: "https://example.com/download?id=audio",
mediaKind: "image",
});
});
it("rejects local paths because LINE outbound media requires public HTTPS URLs", async () => {
await expect(resolveLineOutboundMedia("./assets/image.jpg")).rejects.toThrow(
/requires a public https url/i,
);
});
it("rejects non-HTTPS URL explicitly", async () => {
await expect(resolveLineOutboundMedia("http://example.com/image.jpg")).rejects.toThrow(
/must use HTTPS/i,
);
});
});

View File

@ -0,0 +1,110 @@
export type LineOutboundMediaKind = "image" | "video" | "audio";
export type LineOutboundMediaResolved = {
mediaUrl: string;
mediaKind: LineOutboundMediaKind;
previewImageUrl?: string;
durationMs?: number;
trackingId?: string;
};
type ResolveLineOutboundMediaOpts = {
mediaKind?: LineOutboundMediaKind;
previewImageUrl?: string;
durationMs?: number;
trackingId?: string;
};
export function validateLineMediaUrl(url: string): void {
let parsed: URL;
try {
parsed = new URL(url);
} catch {
throw new Error(`LINE outbound media URL must be a valid URL: ${url}`);
}
if (parsed.protocol !== "https:") {
throw new Error(`LINE outbound media URL must use HTTPS: ${url}`);
}
if (url.length > 2000) {
throw new Error(`LINE outbound media URL must be 2000 chars or less (got ${url.length})`);
}
}
export function detectLineMediaKind(mimeType: string): LineOutboundMediaKind {
const normalized = mimeType.toLowerCase();
if (normalized.startsWith("image/")) {
return "image";
}
if (normalized.startsWith("video/")) {
return "video";
}
if (normalized.startsWith("audio/")) {
return "audio";
}
return "image";
}
function isHttpsUrl(url: string): boolean {
try {
return new URL(url).protocol === "https:";
} catch {
return false;
}
}
function detectLineMediaKindFromUrl(url: string): LineOutboundMediaKind | undefined {
try {
const pathname = new URL(url).pathname.toLowerCase();
if (/\.(png|jpe?g|gif|webp|bmp|heic|heif|avif)$/i.test(pathname)) {
return "image";
}
if (/\.(mp4|mov|m4v|webm)$/i.test(pathname)) {
return "video";
}
if (/\.(mp3|m4a|aac|wav|ogg|oga)$/i.test(pathname)) {
return "audio";
}
} catch {
return undefined;
}
return undefined;
}
export async function resolveLineOutboundMedia(
mediaUrl: string,
opts: ResolveLineOutboundMediaOpts = {},
): Promise<LineOutboundMediaResolved> {
const trimmedUrl = mediaUrl.trim();
if (isHttpsUrl(trimmedUrl)) {
validateLineMediaUrl(trimmedUrl);
const previewImageUrl = opts.previewImageUrl?.trim();
if (previewImageUrl) {
validateLineMediaUrl(previewImageUrl);
}
const mediaKind =
opts.mediaKind ??
(typeof opts.durationMs === "number" ? "audio" : undefined) ??
(opts.trackingId?.trim() ? "video" : undefined) ??
detectLineMediaKindFromUrl(trimmedUrl) ??
"image";
return {
mediaUrl: trimmedUrl,
mediaKind,
...(previewImageUrl ? { previewImageUrl } : {}),
...(typeof opts.durationMs === "number" ? { durationMs: opts.durationMs } : {}),
...(opts.trackingId ? { trackingId: opts.trackingId } : {}),
};
}
try {
const parsed = new URL(trimmedUrl);
if (parsed.protocol !== "https:") {
throw new Error(`LINE outbound media URL must use HTTPS: ${trimmedUrl}`);
}
} catch (e) {
if (e instanceof Error && e.message.startsWith("LINE outbound")) {
throw e;
}
}
throw new Error("LINE outbound media currently requires a public HTTPS URL");
}

View File

@ -9,15 +9,74 @@ import {
type LineChannelData,
type ResolvedLineAccount,
} from "../api.js";
import { resolveLineOutboundMedia, type LineOutboundMediaResolved } from "./outbound-media.js";
import { getLineRuntime } from "./runtime.js";
type LineChannelDataWithMedia = LineChannelData & {
mediaKind?: "image" | "video" | "audio";
previewImageUrl?: string;
durationMs?: number;
trackingId?: string;
};
function isLineUserTarget(target: string): boolean {
const normalized = target
.trim()
.replace(/^line:(group|room|user):/i, "")
.replace(/^line:/i, "");
return /^U/i.test(normalized);
}
function hasLineSpecificMediaOptions(lineData: LineChannelDataWithMedia): boolean {
return Boolean(
lineData.mediaKind ??
lineData.previewImageUrl?.trim() ??
(typeof lineData.durationMs === "number" ? lineData.durationMs : undefined) ??
lineData.trackingId?.trim(),
);
}
function buildLineMediaMessageObject(
resolved: LineOutboundMediaResolved,
opts?: { allowTrackingId?: boolean },
): Record<string, unknown> {
switch (resolved.mediaKind) {
case "video": {
const previewImageUrl = resolved.previewImageUrl?.trim();
if (!previewImageUrl) {
throw new Error("LINE video messages require previewImageUrl to reference an image URL");
}
return {
type: "video",
originalContentUrl: resolved.mediaUrl,
previewImageUrl,
...(opts?.allowTrackingId && resolved.trackingId
? { trackingId: resolved.trackingId }
: {}),
};
}
case "audio":
return {
type: "audio",
originalContentUrl: resolved.mediaUrl,
duration: resolved.durationMs ?? 60000,
};
default:
return {
type: "image",
originalContentUrl: resolved.mediaUrl,
previewImageUrl: resolved.previewImageUrl ?? resolved.mediaUrl,
};
}
}
export const lineOutboundAdapter: NonNullable<ChannelPlugin<ResolvedLineAccount>["outbound"]> = {
deliveryMode: "direct",
chunker: (text, limit) => getLineRuntime().channel.text.chunkMarkdownText(text, limit),
textChunkLimit: 5000,
sendPayload: async ({ to, payload, accountId, cfg }) => {
const runtime = getLineRuntime();
const lineData = (payload.channelData?.line as LineChannelData | undefined) ?? {};
const lineData = (payload.channelData?.line as LineChannelDataWithMedia | undefined) ?? {};
const sendText = runtime.channel.line.pushMessageLine;
const sendBatch = runtime.channel.line.pushMessagesLine;
const sendFlex = runtime.channel.line.pushFlexMessage;
@ -61,12 +120,36 @@ export const lineOutboundAdapter: NonNullable<ChannelPlugin<ResolvedLineAccount>
? runtime.channel.text.chunkMarkdownText(processed.text, chunkLimit)
: [];
const mediaUrls = resolveOutboundMediaUrls(payload);
const useLineSpecificMedia = hasLineSpecificMediaOptions(lineData);
const shouldSendQuickRepliesInline = chunks.length === 0 && hasQuickReplies;
const sendMediaMessages = async () => {
for (const url of mediaUrls) {
const trimmed = url?.trim();
if (!trimmed) {
continue;
}
if (!useLineSpecificMedia) {
lastResult = await runtime.channel.line.sendMessageLine(to, "", {
verbose: false,
mediaUrl: trimmed,
cfg,
accountId: accountId ?? undefined,
});
continue;
}
const resolved = await resolveLineOutboundMedia(trimmed, {
mediaKind: lineData.mediaKind,
previewImageUrl: lineData.previewImageUrl,
durationMs: lineData.durationMs,
trackingId: lineData.trackingId,
});
lastResult = await runtime.channel.line.sendMessageLine(to, "", {
verbose: false,
mediaUrl: url,
mediaUrl: resolved.mediaUrl,
mediaKind: resolved.mediaKind,
previewImageUrl: resolved.previewImageUrl,
durationMs: resolved.durationMs,
trackingId: resolved.trackingId,
cfg,
accountId: accountId ?? undefined,
});
@ -170,11 +253,23 @@ export const lineOutboundAdapter: NonNullable<ChannelPlugin<ResolvedLineAccount>
if (!trimmed) {
continue;
}
quickReplyMessages.push({
type: "image",
originalContentUrl: trimmed,
previewImageUrl: trimmed,
if (!useLineSpecificMedia) {
quickReplyMessages.push({
type: "image",
originalContentUrl: trimmed,
previewImageUrl: trimmed,
});
continue;
}
const resolved = await resolveLineOutboundMedia(trimmed, {
mediaKind: lineData.mediaKind,
previewImageUrl: lineData.previewImageUrl,
durationMs: lineData.durationMs,
trackingId: lineData.trackingId,
});
quickReplyMessages.push(
buildLineMediaMessageObject(resolved, { allowTrackingId: isLineUserTarget(to) }),
);
}
if (quickReplyMessages.length > 0 && quickReply) {
const lastIndex = quickReplyMessages.length - 1;

View File

@ -169,6 +169,64 @@ describe("LINE send helpers", () => {
expect(result).toEqual({ messageId: "reply", chatId: "C1" });
});
it("sends video with explicit image preview URL", async () => {
await sendModule.sendMessageLine("line:user:U100", "Video", {
mediaUrl: "https://example.com/video.mp4",
mediaKind: "video",
previewImageUrl: "https://example.com/preview.jpg",
trackingId: "track-1",
});
expect(pushMessageMock).toHaveBeenCalledWith({
to: "U100",
messages: [
{
type: "video",
originalContentUrl: "https://example.com/video.mp4",
previewImageUrl: "https://example.com/preview.jpg",
trackingId: "track-1",
},
{
type: "text",
text: "Video",
},
],
});
});
it("throws when video preview URL is missing", async () => {
await expect(
sendModule.sendMessageLine("line:user:U200", "Video", {
mediaUrl: "https://example.com/video.mp4",
mediaKind: "video",
}),
).rejects.toThrow(/require previewimageurl/i);
});
it("omits trackingId for non-user destinations", async () => {
await sendModule.sendMessageLine("line:group:C100", "Video", {
mediaUrl: "https://example.com/video.mp4",
mediaKind: "video",
previewImageUrl: "https://example.com/preview.jpg",
trackingId: "track-group",
});
expect(pushMessageMock).toHaveBeenCalledWith({
to: "C100",
messages: [
{
type: "video",
originalContentUrl: "https://example.com/video.mp4",
previewImageUrl: "https://example.com/preview.jpg",
},
{
type: "text",
text: "Video",
},
],
});
});
it("throws when push messages are empty", async () => {
await expect(sendModule.pushMessagesLine("U123", [])).rejects.toThrow(
"Message must be non-empty for LINE sends",

View File

@ -9,6 +9,8 @@ import type { LineSendResult } from "./types.js";
type Message = messagingApi.Message;
type TextMessage = messagingApi.TextMessage;
type ImageMessage = messagingApi.ImageMessage;
type VideoMessage = messagingApi.VideoMessage & { trackingId?: string };
type AudioMessage = messagingApi.AudioMessage;
type LocationMessage = messagingApi.LocationMessage;
type FlexMessage = messagingApi.FlexMessage;
type FlexContainer = messagingApi.FlexContainer;
@ -28,6 +30,10 @@ interface LineSendOpts {
accountId?: string;
verbose?: boolean;
mediaUrl?: string;
mediaKind?: "image" | "video" | "audio";
previewImageUrl?: string;
durationMs?: number;
trackingId?: string;
replyToken?: string;
}
@ -62,6 +68,10 @@ function normalizeTarget(to: string): string {
return normalized;
}
function isLineUserChatId(chatId: string): boolean {
return /^U/i.test(chatId);
}
function createLineMessagingClient(opts: LineClientOpts): {
account: ReturnType<typeof resolveLineAccount>;
client: messagingApi.MessagingApiClient;
@ -106,6 +116,27 @@ export function createImageMessage(
};
}
export function createVideoMessage(
originalContentUrl: string,
previewImageUrl: string,
trackingId?: string,
): VideoMessage {
return {
type: "video",
originalContentUrl,
previewImageUrl,
...(trackingId ? { trackingId } : {}),
};
}
export function createAudioMessage(originalContentUrl: string, durationMs: number): AudioMessage {
return {
type: "audio",
originalContentUrl,
duration: durationMs,
};
}
export function createLocationMessage(location: {
title: string;
address: string;
@ -215,8 +246,27 @@ export async function sendMessageLine(
const chatId = normalizeTarget(to);
const messages: Message[] = [];
if (opts.mediaUrl?.trim()) {
messages.push(createImageMessage(opts.mediaUrl.trim()));
const mediaUrl = opts.mediaUrl?.trim();
if (mediaUrl) {
switch (opts.mediaKind) {
case "video": {
const previewImageUrl = opts.previewImageUrl?.trim();
if (!previewImageUrl) {
throw new Error("LINE video messages require previewImageUrl to reference an image URL");
}
const trackingId = isLineUserChatId(chatId) ? opts.trackingId : undefined;
messages.push(createVideoMessage(mediaUrl, previewImageUrl, trackingId));
break;
}
case "audio":
messages.push(createAudioMessage(mediaUrl, opts.durationMs ?? 60000));
break;
case "image":
default:
// Backward compatibility: keep image as default when media kind is unspecified.
messages.push(createImageMessage(mediaUrl, opts.previewImageUrl?.trim() || mediaUrl));
break;
}
}
if (text?.trim()) {

View File

@ -1,5 +1,6 @@
import { afterEach, describe, expect, it } from "vitest";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { createEmptyPluginRegistry } from "./registry.js";
import type { PluginHttpRouteRegistration } from "./registry.js";
import {
getActivePluginHttpRouteRegistryVersion,
getActivePluginRegistryVersion,
@ -132,3 +133,91 @@ describe("plugin runtime route registry", () => {
});
});
});
const makeRoute = (path: string): PluginHttpRouteRegistration => ({
path,
handler: () => {},
auth: "gateway",
match: "exact",
});
describe("setActivePluginRegistry", () => {
beforeEach(() => {
resetPluginRuntimeStateForTest();
setActivePluginRegistry(createEmptyPluginRegistry());
});
it("does not carry forward httpRoutes when new registry has none", () => {
const oldRegistry = createEmptyPluginRegistry();
const fakeRoute = makeRoute("/test");
oldRegistry.httpRoutes.push(fakeRoute);
setActivePluginRegistry(oldRegistry);
expect(getActivePluginRegistry()?.httpRoutes).toHaveLength(1);
const newRegistry = createEmptyPluginRegistry();
expect(newRegistry.httpRoutes).toHaveLength(0);
setActivePluginRegistry(newRegistry);
expect(getActivePluginRegistry()?.httpRoutes).toHaveLength(0);
});
it("does not carry forward when new registry already has routes", () => {
const oldRegistry = createEmptyPluginRegistry();
oldRegistry.httpRoutes.push(makeRoute("/old"));
setActivePluginRegistry(oldRegistry);
const newRegistry = createEmptyPluginRegistry();
const newRoute = makeRoute("/new");
newRegistry.httpRoutes.push(newRoute);
setActivePluginRegistry(newRegistry);
expect(getActivePluginRegistry()?.httpRoutes).toHaveLength(1);
expect(getActivePluginRegistry()?.httpRoutes[0]).toEqual(newRoute);
});
it("does not carry forward when same registry is set again", () => {
const registry = createEmptyPluginRegistry();
registry.httpRoutes.push(makeRoute("/test"));
setActivePluginRegistry(registry);
setActivePluginRegistry(registry);
expect(getActivePluginRegistry()?.httpRoutes).toHaveLength(1);
});
});
describe("setActivePluginRegistry", () => {
beforeEach(() => {
setActivePluginRegistry(createEmptyPluginRegistry());
});
it("does not carry forward httpRoutes when new registry has none", () => {
const oldRegistry = createEmptyPluginRegistry();
const fakeRoute = makeRoute("/test");
oldRegistry.httpRoutes.push(fakeRoute);
setActivePluginRegistry(oldRegistry);
expect(getActivePluginRegistry()?.httpRoutes).toHaveLength(1);
const newRegistry = createEmptyPluginRegistry();
expect(newRegistry.httpRoutes).toHaveLength(0);
setActivePluginRegistry(newRegistry);
expect(getActivePluginRegistry()?.httpRoutes).toHaveLength(0);
});
it("does not carry forward when new registry already has routes", () => {
const oldRegistry = createEmptyPluginRegistry();
oldRegistry.httpRoutes.push(makeRoute("/old"));
setActivePluginRegistry(oldRegistry);
const newRegistry = createEmptyPluginRegistry();
const newRoute = makeRoute("/new");
newRegistry.httpRoutes.push(newRoute);
setActivePluginRegistry(newRegistry);
expect(getActivePluginRegistry()?.httpRoutes).toHaveLength(1);
expect(getActivePluginRegistry()?.httpRoutes[0]).toEqual(newRoute);
});
it("does not carry forward when same registry is set again", () => {
const registry = createEmptyPluginRegistry();
registry.httpRoutes.push(makeRoute("/test"));
setActivePluginRegistry(registry);
setActivePluginRegistry(registry);
expect(getActivePluginRegistry()?.httpRoutes).toHaveLength(1);
});
});