@@ -110,6 +110,45 @@ function createDeferred<T>(): {
|
110 | 110 | return { promise, reject, resolve }; |
111 | 111 | } |
112 | 112 | |
| 113 | +type CardPayloadWithTextWidgets = { |
| 114 | +cardsV2: Array<{ |
| 115 | +card: { |
| 116 | +sections?: Array<{ |
| 117 | +header?: string; |
| 118 | +widgets?: Array<{ textParagraph?: { text: string } }>; |
| 119 | +}>; |
| 120 | +}; |
| 121 | +}>; |
| 122 | +}; |
| 123 | + |
| 124 | +function getTextParagraphText(payload: unknown, header: string): string { |
| 125 | +const text = (payload as CardPayloadWithTextWidgets).cardsV2[0]?.card.sections?.find( |
| 126 | +(section) => section.header === header, |
| 127 | +)?.widgets?.[0]?.textParagraph?.text; |
| 128 | +if (typeof text !== "string") { |
| 129 | +throw new Error(`Expected ${header} text paragraph`); |
| 130 | +} |
| 131 | +return text; |
| 132 | +} |
| 133 | + |
| 134 | +function isUtf16WellFormed(value: string): boolean { |
| 135 | +for (let index = 0; index < value.length; index += 1) { |
| 136 | +const codeUnit = value.charCodeAt(index); |
| 137 | +if (codeUnit >= 0xd800 && codeUnit <= 0xdbff) { |
| 138 | +const nextCodeUnit = index + 1 < value.length ? value.charCodeAt(index + 1) : -1; |
| 139 | +if (nextCodeUnit < 0xdc00 || nextCodeUnit > 0xdfff) { |
| 140 | +return false; |
| 141 | +} |
| 142 | +index += 1; |
| 143 | +continue; |
| 144 | +} |
| 145 | +if (codeUnit >= 0xdc00 && codeUnit <= 0xdfff) { |
| 146 | +return false; |
| 147 | +} |
| 148 | +} |
| 149 | +return true; |
| 150 | +} |
| 151 | + |
113 | 152 | describe("googleChatApprovalNativeRuntime", () => { |
114 | 153 | async function preparePendingDelivery(view = createPendingView()) { |
115 | 154 | const nowMs = Date.now(); |
@@ -149,6 +188,31 @@ describe("googleChatApprovalNativeRuntime", () => {
|
149 | 188 | return { pendingPayload, plannedTarget, prepared, request, view }; |
150 | 189 | } |
151 | 190 | |
| 191 | +it("keeps truncated pending command card text UTF-16 well formed", async () => { |
| 192 | +const view = createPendingView(); |
| 193 | +view.commandText = `${"a".repeat(1796)}😀${"b".repeat(100)}`; |
| 194 | + |
| 195 | +const { pendingPayload } = await preparePendingDelivery(view); |
| 196 | +const commandText = getTextParagraphText(pendingPayload, "Command"); |
| 197 | + |
| 198 | +expect(commandText.length).toBeLessThanOrEqual(1800); |
| 199 | +expect(commandText.endsWith("...")).toBe(true); |
| 200 | +expect(isUtf16WellFormed(commandText)).toBe(true); |
| 201 | +expect(JSON.stringify(pendingPayload.cardsV2)).not.toContain("\\ud83d"); |
| 202 | +}); |
| 203 | + |
| 204 | +it("preserves a complete astral character when it fits before the truncation suffix", async () => { |
| 205 | +const view = createPendingView(); |
| 206 | +view.commandText = `${"a".repeat(1795)}😀${"b".repeat(100)}`; |
| 207 | + |
| 208 | +const { pendingPayload } = await preparePendingDelivery(view); |
| 209 | +const commandText = getTextParagraphText(pendingPayload, "Command"); |
| 210 | + |
| 211 | +expect(commandText).toBe(`${"a".repeat(1795)}😀...`); |
| 212 | +expect(commandText.length).toBe(1800); |
| 213 | +expect(isUtf16WellFormed(commandText)).toBe(true); |
| 214 | +}); |
| 215 | + |
152 | 216 | it("sends pending cards and updates the delivered message without buttons", async () => { |
153 | 217 | sendGoogleChatMessage.mockResolvedValue({ messageName: "spaces/AAA/messages/msg-1" }); |
154 | 218 | updateGoogleChatMessage.mockResolvedValue({ messageName: "spaces/AAA/messages/msg-1" }); |
|