Vendor OpenClaw source as Adolf fork baseline
Some checks failed
ClawSweeper Dispatch / dispatch (push) Has been cancelled
CodeQL / Security High (actions) (push) Has been cancelled
CodeQL / Security High (channel-runtime-boundary) (push) Has been cancelled
CodeQL / Security High (core-auth-secrets) (push) Has been cancelled
CodeQL / Security High (mcp-process-tool-boundary) (push) Has been cancelled
CodeQL / Security High (network-ssrf-boundary) (push) Has been cancelled
CodeQL / Security High (plugin-trust-boundary) (push) Has been cancelled
CodeQL / Security High (process-exec-boundary) (push) Has been cancelled
Docs Sync Publish Repo / sync-publish-repo (push) Has been cancelled
Docs / docs (push) Has been cancelled
OpenClaw Stable Main Closeout / Resolve stable release closeout inputs (push) Has been cancelled
OpenClaw Stable Main Closeout / Verify stable main closeout (push) Has been cancelled
Workflow Sanity / no-tabs (push) Has been cancelled
Workflow Sanity / actionlint (push) Has been cancelled
Workflow Sanity / generated-doc-baselines (push) Has been cancelled
CI / runner-admission (push) Has been cancelled
CI / preflight (push) Has been cancelled
CI / security-fast (push) Has been cancelled
CI / pnpm-store-warmup (push) Has been cancelled
CI / build-artifacts (push) Has been cancelled
CI / native-i18n (push) Has been cancelled
CI / ${{ matrix.check_name }} (push) Has been cancelled
CI / ${{ matrix.checkName }} (push) Has been cancelled
CI / checks-node-compat-node22 (push) Has been cancelled
CI / check-bundled-channel-config-metadata (push) Has been cancelled
CI / check-dependencies (push) Has been cancelled
CI / check-guards (push) Has been cancelled
CI / check-lint (push) Has been cancelled
CI / check-prod-types (push) Has been cancelled
CI / check-shrinkwrap (push) Has been cancelled
CI / check-test-types (push) Has been cancelled
CI / check-additional-boundaries-a (push) Has been cancelled
CI / check-additional-boundaries-bcd (push) Has been cancelled
CI / check-additional-extension-bundled (push) Has been cancelled
CI / check-additional-extension-channels (push) Has been cancelled
CI / check-additional-extension-package-boundary (push) Has been cancelled
CI / check-additional-runtime-topology-architecture (push) Has been cancelled
CI / check-session-accessor-boundary (push) Has been cancelled
CI / check-session-transcript-reader-boundary (push) Has been cancelled
CI / check-docs (push) Has been cancelled
CI / skills-python (push) Has been cancelled
CI / macos-swift (push) Has been cancelled
CI / ios-build (push) Has been cancelled
CI / ci-timings-summary (push) Has been cancelled
Native App Locale Refresh / Refresh native fa (push) Has been cancelled
Native App Locale Refresh / Refresh native fr (push) Has been cancelled
Native App Locale Refresh / Refresh native hi (push) Has been cancelled
Native App Locale Refresh / Refresh native id (push) Has been cancelled
Native App Locale Refresh / Refresh native it (push) Has been cancelled
Native App Locale Refresh / Refresh native ja-JP (push) Has been cancelled
Control UI Locale Refresh / plan (push) Has been cancelled
Control UI Locale Refresh / Refresh ${{ matrix.locale }} (push) Has been cancelled
Control UI Locale Refresh / Commit control UI locale refresh (push) Has been cancelled
Live Media Runner Image / Build live media runner image (push) Has been cancelled
Native App Locale Refresh / Refresh native ar (push) Has been cancelled
Native App Locale Refresh / Refresh native de (push) Has been cancelled
Native App Locale Refresh / Refresh native es (push) Has been cancelled
Native App Locale Refresh / Refresh native ko (push) Has been cancelled
Native App Locale Refresh / Refresh native nl (push) Has been cancelled
Native App Locale Refresh / Refresh native pl (push) Has been cancelled
Native App Locale Refresh / Refresh native pt-BR (push) Has been cancelled
Native App Locale Refresh / Refresh native ru (push) Has been cancelled
Native App Locale Refresh / Refresh native sv (push) Has been cancelled
Native App Locale Refresh / Refresh native th (push) Has been cancelled
Native App Locale Refresh / Refresh native tr (push) Has been cancelled
Native App Locale Refresh / Refresh native uk (push) Has been cancelled
Native App Locale Refresh / Refresh native vi (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-CN (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-TW (push) Has been cancelled
Native App Locale Refresh / Commit native locale refresh (push) Has been cancelled
Plugin Init Scaffold Validation / Validate provider scaffold (push) Has been cancelled
Plugin NPM Release / preview_plugins_npm (push) Has been cancelled
Plugin NPM Release / Validate release publish approval (push) Has been cancelled
Plugin NPM Release / preview_plugin_pack (push) Has been cancelled
Plugin NPM Release / publish_plugins_npm (push) Has been cancelled
Sandbox Common Smoke / sandbox-common-smoke (push) Has been cancelled
Website Installer Sync / static (push) Has been cancelled
Website Installer Sync / linux-docker (push) Has been cancelled
Website Installer Sync / macos-installer (push) Has been cancelled
Website Installer Sync / windows-installer (push) Has been cancelled
Website Installer Sync / sync-website (push) Has been cancelled
Some checks failed
ClawSweeper Dispatch / dispatch (push) Has been cancelled
CodeQL / Security High (actions) (push) Has been cancelled
CodeQL / Security High (channel-runtime-boundary) (push) Has been cancelled
CodeQL / Security High (core-auth-secrets) (push) Has been cancelled
CodeQL / Security High (mcp-process-tool-boundary) (push) Has been cancelled
CodeQL / Security High (network-ssrf-boundary) (push) Has been cancelled
CodeQL / Security High (plugin-trust-boundary) (push) Has been cancelled
CodeQL / Security High (process-exec-boundary) (push) Has been cancelled
Docs Sync Publish Repo / sync-publish-repo (push) Has been cancelled
Docs / docs (push) Has been cancelled
OpenClaw Stable Main Closeout / Resolve stable release closeout inputs (push) Has been cancelled
OpenClaw Stable Main Closeout / Verify stable main closeout (push) Has been cancelled
Workflow Sanity / no-tabs (push) Has been cancelled
Workflow Sanity / actionlint (push) Has been cancelled
Workflow Sanity / generated-doc-baselines (push) Has been cancelled
CI / runner-admission (push) Has been cancelled
CI / preflight (push) Has been cancelled
CI / security-fast (push) Has been cancelled
CI / pnpm-store-warmup (push) Has been cancelled
CI / build-artifacts (push) Has been cancelled
CI / native-i18n (push) Has been cancelled
CI / ${{ matrix.check_name }} (push) Has been cancelled
CI / ${{ matrix.checkName }} (push) Has been cancelled
CI / checks-node-compat-node22 (push) Has been cancelled
CI / check-bundled-channel-config-metadata (push) Has been cancelled
CI / check-dependencies (push) Has been cancelled
CI / check-guards (push) Has been cancelled
CI / check-lint (push) Has been cancelled
CI / check-prod-types (push) Has been cancelled
CI / check-shrinkwrap (push) Has been cancelled
CI / check-test-types (push) Has been cancelled
CI / check-additional-boundaries-a (push) Has been cancelled
CI / check-additional-boundaries-bcd (push) Has been cancelled
CI / check-additional-extension-bundled (push) Has been cancelled
CI / check-additional-extension-channels (push) Has been cancelled
CI / check-additional-extension-package-boundary (push) Has been cancelled
CI / check-additional-runtime-topology-architecture (push) Has been cancelled
CI / check-session-accessor-boundary (push) Has been cancelled
CI / check-session-transcript-reader-boundary (push) Has been cancelled
CI / check-docs (push) Has been cancelled
CI / skills-python (push) Has been cancelled
CI / macos-swift (push) Has been cancelled
CI / ios-build (push) Has been cancelled
CI / ci-timings-summary (push) Has been cancelled
Native App Locale Refresh / Refresh native fa (push) Has been cancelled
Native App Locale Refresh / Refresh native fr (push) Has been cancelled
Native App Locale Refresh / Refresh native hi (push) Has been cancelled
Native App Locale Refresh / Refresh native id (push) Has been cancelled
Native App Locale Refresh / Refresh native it (push) Has been cancelled
Native App Locale Refresh / Refresh native ja-JP (push) Has been cancelled
Control UI Locale Refresh / plan (push) Has been cancelled
Control UI Locale Refresh / Refresh ${{ matrix.locale }} (push) Has been cancelled
Control UI Locale Refresh / Commit control UI locale refresh (push) Has been cancelled
Live Media Runner Image / Build live media runner image (push) Has been cancelled
Native App Locale Refresh / Refresh native ar (push) Has been cancelled
Native App Locale Refresh / Refresh native de (push) Has been cancelled
Native App Locale Refresh / Refresh native es (push) Has been cancelled
Native App Locale Refresh / Refresh native ko (push) Has been cancelled
Native App Locale Refresh / Refresh native nl (push) Has been cancelled
Native App Locale Refresh / Refresh native pl (push) Has been cancelled
Native App Locale Refresh / Refresh native pt-BR (push) Has been cancelled
Native App Locale Refresh / Refresh native ru (push) Has been cancelled
Native App Locale Refresh / Refresh native sv (push) Has been cancelled
Native App Locale Refresh / Refresh native th (push) Has been cancelled
Native App Locale Refresh / Refresh native tr (push) Has been cancelled
Native App Locale Refresh / Refresh native uk (push) Has been cancelled
Native App Locale Refresh / Refresh native vi (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-CN (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-TW (push) Has been cancelled
Native App Locale Refresh / Commit native locale refresh (push) Has been cancelled
Plugin Init Scaffold Validation / Validate provider scaffold (push) Has been cancelled
Plugin NPM Release / preview_plugins_npm (push) Has been cancelled
Plugin NPM Release / Validate release publish approval (push) Has been cancelled
Plugin NPM Release / preview_plugin_pack (push) Has been cancelled
Plugin NPM Release / publish_plugins_npm (push) Has been cancelled
Sandbox Common Smoke / sandbox-common-smoke (push) Has been cancelled
Website Installer Sync / static (push) Has been cancelled
Website Installer Sync / linux-docker (push) Has been cancelled
Website Installer Sync / macos-installer (push) Has been cancelled
Website Installer Sync / windows-installer (push) Has been cancelled
Website Installer Sync / sync-website (push) Has been cancelled
Adolf is a fork/vendored clone of github.com/openclaw/openclaw (v2026.6.11), free to diverge. Tree copied sans upstream .git; upstream remote added for future syncs. Node pinned to 24 (.nvmrc); engines already require >=22.19. Preserves docs/ARCHITECTURE.md. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LeqyaxJF2nbRXJtae2kNB2
This commit is contained in:
474
extensions/line/src/message-cards.test.ts
Normal file
474
extensions/line/src/message-cards.test.ts
Normal file
@@ -0,0 +1,474 @@
|
||||
// Line tests cover message cards plugin behavior.
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { datetimePickerAction, postbackAction, uriAction } from "./actions.js";
|
||||
import { registerLineCardCommand } from "./card-command.js";
|
||||
import {
|
||||
createActionCard,
|
||||
createCarousel,
|
||||
createDeviceControlCard,
|
||||
createEventCard,
|
||||
createImageCard,
|
||||
createInfoCard,
|
||||
createListCard,
|
||||
createMediaPlayerCard,
|
||||
} from "./flex-templates.js";
|
||||
import {
|
||||
createConfirmTemplate,
|
||||
createButtonTemplate,
|
||||
createTemplateCarousel,
|
||||
createCarouselColumn,
|
||||
createImageCarousel,
|
||||
createImageCarouselColumn,
|
||||
createProductCarousel,
|
||||
messageAction,
|
||||
} from "./template-messages.js";
|
||||
|
||||
const loneHighSurrogate = /[\uD800-\uDBFF](?![\uDC00-\uDFFF])/;
|
||||
|
||||
describe("createConfirmTemplate", () => {
|
||||
it("truncates text to 240 characters", () => {
|
||||
const longText = "x".repeat(300);
|
||||
const template = createConfirmTemplate(longText, messageAction("Yes"), messageAction("No"));
|
||||
|
||||
expect((template.template as { text: string }).text.length).toBe(240);
|
||||
});
|
||||
|
||||
it("drops a surrogate-pair emoji from fallback altText instead of splitting it", () => {
|
||||
const template = createConfirmTemplate(
|
||||
`${"x".repeat(399)}😀`,
|
||||
messageAction("Yes"),
|
||||
messageAction("No"),
|
||||
);
|
||||
|
||||
expect(template.altText).toBe("x".repeat(399));
|
||||
expect(loneHighSurrogate.test(template.altText)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createButtonTemplate", () => {
|
||||
it("limits actions to 4", () => {
|
||||
const actions = Array.from({ length: 6 }, (_, i) => messageAction(`Button ${i}`));
|
||||
const template = createButtonTemplate("Title", "Text", actions);
|
||||
|
||||
expect((template.template as { actions: unknown[] }).actions.length).toBe(4);
|
||||
});
|
||||
|
||||
it("truncates title to 40 characters", () => {
|
||||
const longTitle = "x".repeat(50);
|
||||
const template = createButtonTemplate(longTitle, "Text", [messageAction("OK")]);
|
||||
|
||||
expect((template.template as { title: string }).title.length).toBe(40);
|
||||
});
|
||||
|
||||
it("drops a surrogate-pair emoji from the title instead of splitting it", () => {
|
||||
// 39 chars + an emoji land the truncation boundary inside the surrogate pair;
|
||||
// a raw code-unit slice would keep only the lone high surrogate.
|
||||
const template = createButtonTemplate(`${"x".repeat(39)}😀`, "Text", [messageAction("OK")]);
|
||||
const title = (template.template as { title: string }).title;
|
||||
|
||||
expect(title).toBe("x".repeat(39));
|
||||
expect(loneHighSurrogate.test(title)).toBe(false);
|
||||
});
|
||||
|
||||
it("drops a surrogate-pair emoji from explicit altText instead of splitting it", () => {
|
||||
const template = createButtonTemplate("Title", "Text", [messageAction("OK")], {
|
||||
altText: `${"x".repeat(399)}😀`,
|
||||
});
|
||||
|
||||
expect(template.altText).toBe("x".repeat(399));
|
||||
expect(loneHighSurrogate.test(template.altText)).toBe(false);
|
||||
});
|
||||
|
||||
it("truncates text to 60 chars when no thumbnail is provided", () => {
|
||||
const longText = "x".repeat(100);
|
||||
const template = createButtonTemplate("Title", longText, [messageAction("OK")]);
|
||||
|
||||
expect((template.template as { text: string }).text.length).toBe(60);
|
||||
});
|
||||
|
||||
it("truncates text to 60 chars when title and thumbnail are provided", () => {
|
||||
const longText = "x".repeat(100);
|
||||
const template = createButtonTemplate("Title", longText, [messageAction("OK")], {
|
||||
thumbnailImageUrl: "https://example.com/thumb.jpg",
|
||||
});
|
||||
|
||||
expect((template.template as { text: string }).text.length).toBe(60);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createCarouselColumn", () => {
|
||||
it("limits actions to 3", () => {
|
||||
const column = createCarouselColumn({
|
||||
text: "Text",
|
||||
actions: [
|
||||
messageAction("A1"),
|
||||
messageAction("A2"),
|
||||
messageAction("A3"),
|
||||
messageAction("A4"),
|
||||
messageAction("A5"),
|
||||
],
|
||||
});
|
||||
|
||||
expect(column.actions.length).toBe(3);
|
||||
});
|
||||
|
||||
it("truncates text to 120 characters when no title or image is set", () => {
|
||||
const longText = "x".repeat(150);
|
||||
const column = createCarouselColumn({ text: longText, actions: [messageAction("OK")] });
|
||||
|
||||
expect(column.text.length).toBe(120);
|
||||
});
|
||||
|
||||
it("truncates text to 60 characters when a title is set", () => {
|
||||
const longText = "x".repeat(150);
|
||||
const column = createCarouselColumn({
|
||||
title: "Title",
|
||||
text: longText,
|
||||
actions: [messageAction("OK")],
|
||||
});
|
||||
|
||||
expect(column.text.length).toBe(60);
|
||||
});
|
||||
|
||||
it("drops a surrogate-pair emoji from the title instead of splitting it", () => {
|
||||
const column = createCarouselColumn({
|
||||
title: `${"x".repeat(39)}😀`,
|
||||
text: "Text",
|
||||
actions: [messageAction("OK")],
|
||||
});
|
||||
|
||||
expect(column.title).toBe("x".repeat(39));
|
||||
expect(loneHighSurrogate.test(column.title ?? "")).toBe(false);
|
||||
});
|
||||
|
||||
it("does not split an emoji grapheme at the 60-code-unit boundary", () => {
|
||||
const text = `${"x".repeat(59)}👨👩👧👦after`;
|
||||
const column = createCarouselColumn({
|
||||
title: "Title",
|
||||
text,
|
||||
actions: [messageAction("OK")],
|
||||
});
|
||||
|
||||
expect(column.text).toBe("x".repeat(59));
|
||||
});
|
||||
|
||||
it("keeps required text when the first grapheme exceeds the limit", () => {
|
||||
const text = `😀${"\u0301".repeat(59)}`;
|
||||
const column = createCarouselColumn({
|
||||
title: "Title",
|
||||
text,
|
||||
actions: [messageAction("OK")],
|
||||
});
|
||||
|
||||
expect(column.text.length).toBe(60);
|
||||
expect(column.text.startsWith("😀")).toBe(true);
|
||||
});
|
||||
|
||||
it("uses the compact limit when a whitespace-only title is present", () => {
|
||||
const column = createCarouselColumn({
|
||||
title: " ",
|
||||
text: "x".repeat(150),
|
||||
actions: [messageAction("OK")],
|
||||
});
|
||||
|
||||
expect(column.text).toBe("x".repeat(60));
|
||||
});
|
||||
|
||||
it("truncates text to 60 characters when a thumbnail image is set", () => {
|
||||
const longText = "x".repeat(150);
|
||||
const column = createCarouselColumn({
|
||||
text: longText,
|
||||
thumbnailImageUrl: "https://example.com/thumb.jpg",
|
||||
actions: [messageAction("OK")],
|
||||
});
|
||||
|
||||
expect(column.text.length).toBe(60);
|
||||
});
|
||||
});
|
||||
|
||||
describe("carousel column limits", () => {
|
||||
it.each([
|
||||
{
|
||||
createTemplate: () =>
|
||||
createTemplateCarousel(
|
||||
Array.from({ length: 15 }, () =>
|
||||
createCarouselColumn({ text: "Text", actions: [messageAction("OK")] }),
|
||||
),
|
||||
),
|
||||
},
|
||||
{
|
||||
createTemplate: () =>
|
||||
createImageCarousel(
|
||||
Array.from({ length: 15 }, (_, i) =>
|
||||
createImageCarouselColumn(`https://example.com/${i}.jpg`, messageAction("View")),
|
||||
),
|
||||
),
|
||||
},
|
||||
])("limits columns to 10", ({ createTemplate }) => {
|
||||
const template = createTemplate();
|
||||
expect((template.template as { columns: unknown[] }).columns.length).toBe(10);
|
||||
});
|
||||
|
||||
it("drops a surrogate-pair emoji from image-carousel altText instead of splitting it", () => {
|
||||
const template = createImageCarousel(
|
||||
[createImageCarouselColumn("https://example.com/0.jpg", messageAction("View"))],
|
||||
`${"x".repeat(399)}😀`,
|
||||
);
|
||||
|
||||
expect(template.altText).toBe("x".repeat(399));
|
||||
expect(loneHighSurrogate.test(template.altText)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createProductCarousel", () => {
|
||||
it.each([
|
||||
{
|
||||
title: "Product",
|
||||
description: "Desc",
|
||||
actionLabel: "Buy",
|
||||
actionUrl: "https://shop.com/buy",
|
||||
expectedType: "uri",
|
||||
},
|
||||
{
|
||||
title: "Product",
|
||||
description: "Desc",
|
||||
actionLabel: "Select",
|
||||
actionData: "product_id=123",
|
||||
expectedType: "postback",
|
||||
},
|
||||
])("uses expected action type for product action", ({ expectedType, ...item }) => {
|
||||
const template = createProductCarousel([item]);
|
||||
const columns = (template.template as { columns: Array<{ actions: Array<{ type: string }> }> })
|
||||
.columns;
|
||||
expect(columns[0].actions[0].type).toBe(expectedType);
|
||||
});
|
||||
|
||||
it("preserves the complete price when truncating a long description", () => {
|
||||
const template = createProductCarousel([
|
||||
{
|
||||
title: "Product",
|
||||
description: "x".repeat(59),
|
||||
price: "$12.99",
|
||||
},
|
||||
]);
|
||||
const columns = (template.template as { columns: Array<{ text: string }> }).columns;
|
||||
|
||||
expect(columns[0].text).toBe(`${"x".repeat(53)}\n$12.99`);
|
||||
expect(columns[0].text.length).toBe(60);
|
||||
});
|
||||
});
|
||||
|
||||
describe("flex cards", () => {
|
||||
it("includes footer when provided", () => {
|
||||
const card = createInfoCard("Title", "Body", "Footer text");
|
||||
|
||||
const footer = card.footer as { contents: Array<{ text: string }> };
|
||||
expect(footer.contents[0].text).toBe("Footer text");
|
||||
});
|
||||
|
||||
it("limits list items to 8", () => {
|
||||
const items = Array.from({ length: 15 }, (_, i) => ({ title: `Item ${i}` }));
|
||||
const card = createListCard("List", items);
|
||||
|
||||
const body = card.body as { contents: Array<{ type: string; contents?: unknown[] }> };
|
||||
const listBox = body.contents[2] as { contents: unknown[] };
|
||||
expect(listBox.contents.length).toBe(8);
|
||||
});
|
||||
|
||||
it("includes image-card body text when provided", () => {
|
||||
const card = createImageCard("https://example.com/img.jpg", "Title", "Body text");
|
||||
|
||||
const body = card.body as { contents: Array<{ text: string }> };
|
||||
expect(body.contents.length).toBe(2);
|
||||
expect(body.contents[1].text).toBe("Body text");
|
||||
});
|
||||
|
||||
it("limits action-card actions to 4", () => {
|
||||
const actions = Array.from({ length: 6 }, (_, i) => ({
|
||||
label: `Action ${i}`,
|
||||
action: { type: "message" as const, label: `A${i}`, text: `action${i}` },
|
||||
}));
|
||||
const card = createActionCard("Title", "Body", actions);
|
||||
|
||||
const footer = card.footer as { contents: unknown[] };
|
||||
expect(footer.contents.length).toBe(4);
|
||||
});
|
||||
|
||||
it("limits carousels to 12 bubbles", () => {
|
||||
const bubbles = Array.from({ length: 15 }, (_, i) => createInfoCard(`Card ${i}`, `Body ${i}`));
|
||||
const carousel = createCarousel(bubbles);
|
||||
|
||||
expect(carousel.contents.length).toBe(12);
|
||||
});
|
||||
|
||||
it("limits device controls to 6", () => {
|
||||
const card = createDeviceControlCard({
|
||||
deviceName: "Device",
|
||||
controls: Array.from({ length: 10 }, (_, i) => ({
|
||||
label: `Control ${i}`,
|
||||
data: `action=${i}`,
|
||||
})),
|
||||
});
|
||||
|
||||
const footer = card.footer as { contents: unknown[] };
|
||||
expect(footer.contents.length).toBeLessThanOrEqual(3);
|
||||
});
|
||||
|
||||
it("keeps event-card optional fields together", () => {
|
||||
const card = createEventCard({
|
||||
title: "Team Offsite",
|
||||
date: "February 15, 2026",
|
||||
time: "9:00 AM - 5:00 PM",
|
||||
location: "Mountain View Office",
|
||||
description: "Annual team building event",
|
||||
});
|
||||
|
||||
expect(card.size).toBe("mega");
|
||||
const body = card.body as { contents: Array<{ type: string }> };
|
||||
expect(body.contents).toHaveLength(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe("action label/data surrogate-safe truncation", () => {
|
||||
// 19 ASCII chars + 😀 (U+1F600, two UTF-16 code units) = 21 code units; a raw
|
||||
// .slice(0, 20) would keep the first 19 chars plus the lone high surrogate.
|
||||
const labelWithEmoji = "1234567890123456789😀";
|
||||
|
||||
it("messageAction drops a half emoji instead of leaving a lone surrogate", () => {
|
||||
const action = messageAction(labelWithEmoji) as { label: string };
|
||||
|
||||
expect(action.label).toBe("1234567890123456789");
|
||||
expect(loneHighSurrogate.test(action.label)).toBe(false);
|
||||
});
|
||||
|
||||
it("messageAction leaves a short ASCII label unchanged", () => {
|
||||
const action = messageAction("Yes");
|
||||
|
||||
expect(action.label).toBe("Yes");
|
||||
});
|
||||
|
||||
it("uriAction drops a half emoji instead of leaving a lone surrogate", () => {
|
||||
const action = uriAction(labelWithEmoji, "https://example.com") as { label: string };
|
||||
|
||||
expect(action.label).toBe("1234567890123456789");
|
||||
expect(loneHighSurrogate.test(action.label)).toBe(false);
|
||||
});
|
||||
|
||||
it("postbackAction truncates label and data on surrogate boundaries", () => {
|
||||
// 299 ASCII chars + 😀 = 301 code units; the 300-unit slice cuts the emoji.
|
||||
const data = `${"d".repeat(299)}😀`;
|
||||
const action = postbackAction(labelWithEmoji, data) as {
|
||||
label: string;
|
||||
data: string;
|
||||
};
|
||||
|
||||
expect(action.label).toBe("1234567890123456789");
|
||||
expect(loneHighSurrogate.test(action.label)).toBe(false);
|
||||
expect(action.data).toBe("d".repeat(299));
|
||||
expect(loneHighSurrogate.test(action.data)).toBe(false);
|
||||
});
|
||||
|
||||
it("postbackAction truncates displayText on surrogate boundaries but keeps undefined", () => {
|
||||
const displayText = `${"t".repeat(299)}😀`;
|
||||
const withDisplay = postbackAction("Label", "data", displayText) as {
|
||||
displayText?: string;
|
||||
};
|
||||
const withoutDisplay = postbackAction("Label", "data") as { displayText?: string };
|
||||
|
||||
expect(withDisplay.displayText).toBe("t".repeat(299));
|
||||
expect(loneHighSurrogate.test(withDisplay.displayText ?? "")).toBe(false);
|
||||
expect(withoutDisplay.displayText).toBeUndefined();
|
||||
});
|
||||
|
||||
it("datetimePickerAction truncates label and data on surrogate boundaries", () => {
|
||||
const data = `${"d".repeat(299)}😀`;
|
||||
const action = datetimePickerAction(labelWithEmoji, data, "datetime") as {
|
||||
label: string;
|
||||
data: string;
|
||||
};
|
||||
|
||||
expect(action.label).toBe("1234567890123456789");
|
||||
expect(loneHighSurrogate.test(action.label)).toBe(false);
|
||||
expect(action.data).toBe("d".repeat(299));
|
||||
expect(loneHighSurrogate.test(action.data)).toBe(false);
|
||||
});
|
||||
|
||||
it("/card action command uses surrogate-safe labels and postback data", async () => {
|
||||
const registerCommand = (command: unknown) => {
|
||||
const { handler } = command as {
|
||||
handler: (ctx: { args: string; channel: string }) => Promise<unknown>;
|
||||
};
|
||||
return handler({
|
||||
channel: "line",
|
||||
args: `action "Menu" "Body" --actions "${labelWithEmoji}|k=${"d".repeat(297)}😀"`,
|
||||
});
|
||||
};
|
||||
const result = (await registerCommandWithHandler(registerCommand)) as {
|
||||
channelData: {
|
||||
line: {
|
||||
flexMessage: {
|
||||
contents: { footer: { contents: Array<{ action: { label: string; data: string } }> } };
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
const action = result.channelData.line.flexMessage.contents.footer.contents[0].action;
|
||||
|
||||
expect(action.label).toBe("1234567890123456789");
|
||||
expect(loneHighSurrogate.test(action.label)).toBe(false);
|
||||
expect(action.data).toBe(`k=${"d".repeat(297)}`);
|
||||
expect(loneHighSurrogate.test(action.data)).toBe(false);
|
||||
});
|
||||
|
||||
it("/card receipt altText truncates on a surrogate boundary", async () => {
|
||||
// The emoji's surrogate pair straddles the 400-char altText cap; a raw
|
||||
// slice used to leave a lone high surrogate in the receipt flex altText.
|
||||
const registerCommand = (command: unknown) => {
|
||||
const { handler } = command as {
|
||||
handler: (ctx: { args: string; channel: string }) => Promise<unknown>;
|
||||
};
|
||||
return handler({
|
||||
channel: "line",
|
||||
args: `receipt "R" "${"a".repeat(395)}:😀x" --total "$30"`,
|
||||
});
|
||||
};
|
||||
const result = (await registerCommandWithHandler(registerCommand)) as {
|
||||
channelData: { line: { flexMessage: { altText: string } } };
|
||||
};
|
||||
const altText = result.channelData.line.flexMessage.altText;
|
||||
|
||||
expect(altText.length).toBeLessThanOrEqual(400);
|
||||
expect(loneHighSurrogate.test(altText)).toBe(false);
|
||||
});
|
||||
|
||||
it("media control postback labels truncate on surrogate boundaries", () => {
|
||||
const card = createMediaPlayerCard({
|
||||
title: "Track",
|
||||
controls: {
|
||||
play: { data: "play" },
|
||||
},
|
||||
extraActions: [{ label: `${"x".repeat(14)}😀`, data: "extra" }],
|
||||
});
|
||||
const footer = card.footer as {
|
||||
contents: Array<{ contents?: Array<{ action?: { data?: string; label: string } }> }>;
|
||||
};
|
||||
const extraAction = footer.contents
|
||||
.flatMap((content) => content.contents ?? [])
|
||||
.find((button) => button.action?.data === "extra")?.action;
|
||||
|
||||
expect(extraAction?.label).toBe("x".repeat(14));
|
||||
expect(loneHighSurrogate.test(extraAction?.label ?? "")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
async function registerCommandWithHandler(
|
||||
runHandler: (command: unknown) => Promise<unknown>,
|
||||
): Promise<unknown> {
|
||||
let result: unknown;
|
||||
registerLineCardCommand({
|
||||
registerCommand(command: unknown) {
|
||||
result = runHandler(command);
|
||||
},
|
||||
} as never);
|
||||
return result;
|
||||
}
|
||||
Reference in New Issue
Block a user