@handled-ai/design-system 0.20.4 → 0.20.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/components/conversation-panel.d.ts +28 -1
- package/dist/components/conversation-panel.js +180 -310
- package/dist/components/conversation-panel.js.map +1 -1
- package/dist/components/email-body.d.ts +15 -0
- package/dist/components/email-body.js +101 -0
- package/dist/components/email-body.js.map +1 -0
- package/dist/components/email-display-helpers.d.ts +34 -0
- package/dist/components/email-display-helpers.js +436 -0
- package/dist/components/email-display-helpers.js.map +1 -0
- package/dist/components/email-preview-card.d.ts +7 -4
- package/dist/components/email-preview-card.js +48 -25
- package/dist/components/email-preview-card.js.map +1 -1
- package/dist/components/timeline-activity.js +66 -42
- package/dist/components/timeline-activity.js.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/internal/safe-html.d.ts +1 -1
- package/dist/internal/safe-html.js +64 -3
- package/dist/internal/safe-html.js.map +1 -1
- package/package.json +1 -1
- package/src/components/__tests__/conversation-panel.test.tsx +230 -22
- package/src/components/__tests__/email-body.test.tsx +83 -0
- package/src/components/__tests__/email-display-helpers.test.ts +91 -0
- package/src/components/__tests__/email-preview-card.test.tsx +36 -2
- package/src/components/__tests__/timeline-activity.test.tsx +53 -1
- package/src/components/conversation-panel.tsx +227 -369
- package/src/components/email-body.tsx +126 -0
- package/src/components/email-display-helpers.ts +557 -0
- package/src/components/email-preview-card.tsx +54 -29
- package/src/components/timeline-activity.tsx +73 -53
- package/src/index.ts +2 -0
- package/src/internal/__tests__/safe-html.test.ts +34 -2
- package/src/internal/safe-html.ts +79 -4
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/internal/safe-html.ts"],"sourcesContent":["const DANGEROUS_BLOCK_TAGS = new Set([\n \"script\",\n \"style\",\n \"iframe\",\n \"object\",\n \"embed\",\n \"svg\",\n \"math\",\n \"template\",\n \"noscript\",\n \"textarea\",\n \"select\",\n])\n\nconst ALLOWED_TAGS = new Set([\n \"a\",\n \"b\",\n \"blockquote\",\n \"br\",\n \"code\",\n \"del\",\n \"div\",\n \"em\",\n \"hr\",\n \"i\",\n \"img\",\n \"li\",\n \"ol\",\n \"p\",\n \"pre\",\n \"s\",\n \"span\",\n \"strong\",\n \"table\",\n \"tbody\",\n \"td\",\n \"th\",\n \"thead\",\n \"tr\",\n \"u\",\n \"ul\",\n])\n\nconst VOID_TAGS = new Set([\"br\", \"hr\", \"img\"])\nconst SAFE_GLOBAL_ATTRS = new Set([\"aria-label\", \"role\", \"title\"])\nconst SAFE_URL_PROTOCOLS = new Set([\"http:\", \"https:\", \"mailto:\", \"tel:\"])\n\nfunction escapeHtml(value: string): string {\n return value\n .replace(/&/g, \"&\")\n .replace(/</g, \"<\")\n .replace(/>/g, \">\")\n}\n\nfunction escapeAttribute(value: string): string {\n return escapeHtml(value).replace(/\"/g, \""\")\n}\n\nfunction safeCodePoint(value: number): string {\n return Number.isInteger(value) && value >= 0 && value <= 0x10ffff ? String.fromCodePoint(value) : \"\"\n}\n\nfunction decodeHtmlEntities(value: string): string {\n const namedEntities: Record<string, string> = {\n amp: \"&\",\n apos: \"'\",\n colon: \":\",\n gt: \">\",\n lt: \"<\",\n newline: \"\\n\",\n quot: '\"',\n tab: \"\\t\",\n }\n\n let decoded = value\n for (let i = 0; i < 4; i += 1) {\n const next = decoded\n .replace(/&#x([0-9a-f]+);?/gi, (_match, hex: string) => {\n const codePoint = Number.parseInt(hex, 16)\n return safeCodePoint(codePoint)\n })\n .replace(/&#(\\d+);?/g, (_match, decimal: string) => {\n const codePoint = Number.parseInt(decimal, 10)\n return safeCodePoint(codePoint)\n })\n .replace(/&([a-z]+);/gi, (match, name: string) => namedEntities[name.toLowerCase()] ?? match)\n\n if (next === decoded) return decoded\n decoded = next\n }\n\n return decoded\n}\n\nfunction isSafeUrl(value: string): boolean {\n const decoded = decodeHtmlEntities(value).replace(/[\\u0000-\\u001f\\u007f\\s]+/g, \"\").trim()\n if (!decoded) return false\n if (decoded.startsWith(\"//\")) return false\n if (decoded.startsWith(\"#\") || decoded.startsWith(\"/\") || decoded.startsWith(\"./\") || decoded.startsWith(\"../\")) {\n return true\n }\n\n try {\n return SAFE_URL_PROTOCOLS.has(new URL(decoded, \"https://handled.local\").protocol)\n } catch {\n return false\n }\n}\n\nfunction sanitizeClassName(value: string): string | null {\n const safeTokens = value\n .split(/\\s+/)\n .map((token) => token.trim())\n .filter((token) => /^[A-Za-z0-9_-]+$/.test(token))\n\n return safeTokens.length ? safeTokens.join(\" \") : null\n}\n\nfunction sanitizeAttribute(tagName: string, name: string, value: string): string | null {\n const attr = name.toLowerCase()\n\n if (\n attr.startsWith(\"on\") ||\n attr === \"style\" ||\n attr === \"srcdoc\" ||\n attr === \"formaction\" ||\n attr === \"xlink:href\" ||\n attr === \"xmlns\"\n ) {\n return null\n }\n\n if (attr === \"class\") {\n const safeClassName = sanitizeClassName(value)\n return safeClassName ? `class=\"${escapeAttribute(safeClassName)}\"` : null\n }\n\n if (SAFE_GLOBAL_ATTRS.has(attr) || attr.startsWith(\"aria-\")) {\n return `${attr}=\"${escapeAttribute(value)}\"`\n }\n\n if (tagName === \"a\" && attr === \"href\" && isSafeUrl(value)) {\n return `href=\"${escapeAttribute(value)}\"`\n }\n\n if (tagName === \"img\" && attr === \"src\" && isSafeUrl(value)) {\n return `src=\"${escapeAttribute(value)}\"`\n }\n\n if (tagName === \"img\" && (attr === \"alt\" || attr === \"width\" || attr === \"height\")) {\n return `${attr}=\"${escapeAttribute(value)}\"`\n }\n\n if ((tagName === \"td\" || tagName === \"th\") && (attr === \"colspan\" || attr === \"rowspan\")) {\n return `${attr}=\"${escapeAttribute(value)}\"`\n }\n\n return null\n}\n\nfunction sanitizeAttributes(tagName: string, rawAttributes = \"\"): string {\n const attributes: string[] = []\n const attrPattern = /([A-Za-z_:][-A-Za-z0-9_:.]*)(?:\\s*=\\s*(?:\"([^\"]*)\"|'([^']*)'|([^\\s\"'=<>`]+)))?/g\n let match: RegExpExecArray | null\n\n while ((match = attrPattern.exec(rawAttributes)) !== null) {\n const [, name, doubleQuotedValue, singleQuotedValue, unquotedValue] = match\n const value = doubleQuotedValue ?? singleQuotedValue ?? unquotedValue ?? \"\"\n const safeAttribute = sanitizeAttribute(tagName, name, value)\n if (safeAttribute) attributes.push(safeAttribute)\n }\n\n if (tagName === \"a\" && attributes.some((attr) => attr.startsWith(\"href=\"))) {\n attributes.push('target=\"_blank\"', 'rel=\"noopener noreferrer\"')\n }\n\n return attributes.length ? ` ${attributes.join(\" \")}` : \"\"\n}\n\nfunction findTagEnd(html: string, startIndex: number): number {\n let quote: '\"' | \"'\" | null = null\n\n for (let i = startIndex + 1; i < html.length; i += 1) {\n const char = html[i]\n if (quote) {\n if (char === quote) quote = null\n continue\n }\n\n if (char === '\"' || char === \"'\") {\n quote = char\n continue\n }\n\n if (char === \">\") return i\n }\n\n return -1\n}\n\nfunction parseTag(rawTag: string): { closing: boolean; name: string; attributes: string } | null {\n const match = rawTag.match(/^<\\s*(\\/)?\\s*([A-Za-z][A-Za-z0-9:-]*)\\b([\\s\\S]*?)\\/?>$/)\n if (!match) return null\n\n return {\n closing: !!match[1],\n name: match[2].toLowerCase(),\n attributes: match[3] ?? \"\",\n }\n}\n\nfunction findDangerousClose(html: string, tagName: string, fromIndex: number): number {\n const closePattern = new RegExp(`</\\\\s*${tagName}\\\\s*>`, \"ig\")\n closePattern.lastIndex = fromIndex\n const match = closePattern.exec(html)\n return match ? closePattern.lastIndex : -1\n}\n\n/**\n * Conservative, deterministic sanitizer for user/email supplied HTML rendered by\n * design-system components. It keeps common email formatting tags while removing\n * executable tags, event handlers, inline styles, and unsafe URLs. This stays\n * dependency-free for the shared package and intentionally favors stripping\n * ambiguous email content over preserving every possible HTML feature.\n */\nexport function sanitizeHtml(html: string): string {\n let output = \"\"\n let cursor = 0\n\n while (cursor < html.length) {\n const tagStart = html.indexOf(\"<\", cursor)\n if (tagStart === -1) {\n output += html.slice(cursor)\n break\n }\n\n output += html.slice(cursor, tagStart)\n\n if (html.startsWith(\"<!--\", tagStart)) {\n const commentEnd = html.indexOf(\"-->\", tagStart + 4)\n cursor = commentEnd === -1 ? html.length : commentEnd + 3\n continue\n }\n\n const tagEnd = findTagEnd(html, tagStart)\n if (tagEnd === -1) {\n output += escapeHtml(html.slice(tagStart))\n break\n }\n\n const rawTag = html.slice(tagStart, tagEnd + 1)\n const parsed = parseTag(rawTag)\n if (!parsed) {\n cursor = tagEnd + 1\n continue\n }\n\n if (DANGEROUS_BLOCK_TAGS.has(parsed.name)) {\n const closeEnd = parsed.closing ? -1 : findDangerousClose(html, parsed.name, tagEnd + 1)\n cursor = closeEnd === -1 ? tagEnd + 1 : closeEnd\n continue\n }\n\n if (ALLOWED_TAGS.has(parsed.name)) {\n if (parsed.closing) {\n if (!VOID_TAGS.has(parsed.name)) output += `</${parsed.name}>`\n } else {\n output += `<${parsed.name}${sanitizeAttributes(parsed.name, parsed.attributes)}>`\n }\n }\n\n cursor = tagEnd + 1\n }\n\n return output\n}\n\nexport function htmlToTextSnippet(html: string, maxLength = 140): string {\n return sanitizeHtml(html)\n .replace(/<[^>]+>/g, \" \")\n .replace(/\\s+/g, \" \")\n .trim()\n .slice(0, maxLength)\n}\n"],"mappings":"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","names":[]}
|
|
1
|
+
{"version":3,"sources":["../../src/internal/safe-html.ts"],"sourcesContent":["const DANGEROUS_BLOCK_TAGS = new Set([\n \"script\",\n \"style\",\n \"iframe\",\n \"object\",\n \"embed\",\n \"svg\",\n \"math\",\n \"template\",\n \"noscript\",\n \"textarea\",\n \"select\",\n])\n\nconst ALLOWED_TAGS = new Set([\n \"a\",\n \"b\",\n \"blockquote\",\n \"br\",\n \"code\",\n \"del\",\n \"div\",\n \"em\",\n \"hr\",\n \"i\",\n \"img\",\n \"li\",\n \"ol\",\n \"p\",\n \"pre\",\n \"s\",\n \"span\",\n \"strong\",\n \"sub\",\n \"sup\",\n \"table\",\n \"tbody\",\n \"td\",\n \"th\",\n \"thead\",\n \"tr\",\n \"u\",\n \"ul\",\n])\n\nconst VOID_TAGS = new Set([\"br\", \"hr\", \"img\"])\nconst SAFE_GLOBAL_ATTRS = new Set([\"aria-label\", \"dir\", \"lang\", \"role\", \"title\"])\nconst SAFE_URL_PROTOCOLS = new Set([\"http:\", \"https:\", \"mailto:\", \"tel:\"])\n\nfunction escapeHtml(value: string): string {\n return value\n .replace(/&/g, \"&\")\n .replace(/</g, \"<\")\n .replace(/>/g, \">\")\n}\n\nfunction escapeAttribute(value: string): string {\n return escapeHtml(value).replace(/\"/g, \""\")\n}\n\nfunction safeCodePoint(value: number): string {\n return Number.isInteger(value) && value >= 0 && value <= 0x10ffff ? String.fromCodePoint(value) : \"\"\n}\n\nfunction decodeHtmlEntities(value: string): string {\n const namedEntities: Record<string, string> = {\n amp: \"&\",\n apos: \"'\",\n colon: \":\",\n gt: \">\",\n lt: \"<\",\n newline: \"\\n\",\n quot: '\"',\n tab: \"\\t\",\n }\n\n let decoded = value\n for (let i = 0; i < 4; i += 1) {\n const next = decoded\n .replace(/&#x([0-9a-f]+);?/gi, (_match, hex: string) => {\n const codePoint = Number.parseInt(hex, 16)\n return safeCodePoint(codePoint)\n })\n .replace(/&#(\\d+);?/g, (_match, decimal: string) => {\n const codePoint = Number.parseInt(decimal, 10)\n return safeCodePoint(codePoint)\n })\n .replace(/&([a-z]+);/gi, (match, name: string) => namedEntities[name.toLowerCase()] ?? match)\n\n if (next === decoded) return decoded\n decoded = next\n }\n\n return decoded\n}\n\nfunction isSafeUrl(value: string): boolean {\n const decoded = decodeHtmlEntities(value).replace(/[\\u0000-\\u001f\\u007f\\s]+/g, \"\").trim()\n if (!decoded) return false\n if (decoded.startsWith(\"//\")) return false\n if (decoded.startsWith(\"#\") || decoded.startsWith(\"/\") || decoded.startsWith(\"./\") || decoded.startsWith(\"../\")) {\n return true\n }\n\n try {\n return SAFE_URL_PROTOCOLS.has(new URL(decoded, \"https://handled.local\").protocol)\n } catch {\n return false\n }\n}\n\nfunction sanitizeClassName(value: string): string | null {\n const safeTokens = value\n .split(/\\s+/)\n .map((token) => token.trim())\n .filter((token) => /^[A-Za-z0-9_-]+$/.test(token))\n\n return safeTokens.length ? safeTokens.join(\" \") : null\n}\n\nfunction sanitizeDimension(value: string): string | null {\n const trimmed = value.trim()\n if (/^\\d{1,4}$/.test(trimmed)) return trimmed\n return null\n}\n\nfunction sanitizeLanguage(value: string): string | null {\n const trimmed = value.trim()\n if (/^[A-Za-z]{1,8}(?:-[A-Za-z0-9]{1,8})*$/.test(trimmed)) return trimmed\n return null\n}\n\nfunction sanitizeDirection(value: string): string | null {\n const trimmed = value.trim().toLowerCase()\n return trimmed === \"ltr\" || trimmed === \"rtl\" || trimmed === \"auto\" ? trimmed : null\n}\n\nfunction sanitizeFontSize(value: string): string | null {\n const trimmed = value.trim().toLowerCase()\n const match = trimmed.match(/^(\\d+(?:\\.\\d+)?)(px|em|rem|%)$/)\n if (!match) return null\n\n const amount = Number.parseFloat(match[1])\n const unit = match[2]\n const maxByUnit: Record<string, number> = { px: 72, em: 4, rem: 4, \"%\": 400 }\n if (!Number.isFinite(amount) || amount <= 0 || amount > maxByUnit[unit]) return null\n return `${amount}${unit}`\n}\n\nfunction sanitizeStyle(value: string): string | null {\n const declarations: string[] = []\n\n for (const rawDeclaration of value.split(\";\")) {\n const separatorIndex = rawDeclaration.indexOf(\":\")\n if (separatorIndex === -1) continue\n\n const property = rawDeclaration.slice(0, separatorIndex).trim().toLowerCase()\n const rawValue = decodeHtmlEntities(rawDeclaration.slice(separatorIndex + 1)).trim().toLowerCase()\n if (!property || !rawValue || /url\\s*\\(|expression\\s*\\(/i.test(rawValue)) continue\n\n if (property === \"vertical-align\" && (rawValue === \"super\" || rawValue === \"sub\")) {\n declarations.push(`vertical-align: ${rawValue}`)\n continue\n }\n\n if (property === \"font-size\") {\n const fontSize = sanitizeFontSize(rawValue)\n if (fontSize) declarations.push(`font-size: ${fontSize}`)\n }\n }\n\n return declarations.length ? declarations.join(\"; \") : null\n}\n\nfunction sanitizeAttribute(tagName: string, name: string, value: string): string | null {\n const attr = name.toLowerCase()\n\n if (\n attr.startsWith(\"on\") ||\n attr === \"srcdoc\" ||\n attr === \"formaction\" ||\n attr === \"xlink:href\" ||\n attr === \"xmlns\"\n ) {\n return null\n }\n\n if (attr === \"style\") {\n const safeStyle = sanitizeStyle(value)\n return safeStyle ? `style=\"${escapeAttribute(safeStyle)}\"` : null\n }\n\n if (attr === \"class\") {\n const safeClassName = sanitizeClassName(value)\n return safeClassName ? `class=\"${escapeAttribute(safeClassName)}\"` : null\n }\n\n if (attr === \"dir\") {\n const safeDirection = sanitizeDirection(value)\n return safeDirection ? `dir=\"${escapeAttribute(safeDirection)}\"` : null\n }\n\n if (attr === \"lang\") {\n const safeLanguage = sanitizeLanguage(value)\n return safeLanguage ? `lang=\"${escapeAttribute(safeLanguage)}\"` : null\n }\n\n if (SAFE_GLOBAL_ATTRS.has(attr) || attr.startsWith(\"aria-\")) {\n return `${attr}=\"${escapeAttribute(value)}\"`\n }\n\n if (tagName === \"a\" && attr === \"href\" && isSafeUrl(value)) {\n return `href=\"${escapeAttribute(value)}\"`\n }\n\n if (tagName === \"img\" && attr === \"src\" && isSafeUrl(value)) {\n return `src=\"${escapeAttribute(value)}\"`\n }\n\n if (tagName === \"img\" && attr === \"alt\") {\n return `${attr}=\"${escapeAttribute(value)}\"`\n }\n\n if (tagName === \"img\" && (attr === \"width\" || attr === \"height\")) {\n const safeDimension = sanitizeDimension(value)\n return safeDimension ? `${attr}=\"${escapeAttribute(safeDimension)}\"` : null\n }\n\n if ((tagName === \"td\" || tagName === \"th\") && (attr === \"colspan\" || attr === \"rowspan\")) {\n return `${attr}=\"${escapeAttribute(value)}\"`\n }\n\n return null\n}\n\nfunction sanitizeAttributes(tagName: string, rawAttributes = \"\"): string {\n const attributes: string[] = []\n const attrPattern = /([A-Za-z_:][-A-Za-z0-9_:.]*)(?:\\s*=\\s*(?:\"([^\"]*)\"|'([^']*)'|([^\\s\"'=<>`]+)))?/g\n let match: RegExpExecArray | null\n\n while ((match = attrPattern.exec(rawAttributes)) !== null) {\n const [, name, doubleQuotedValue, singleQuotedValue, unquotedValue] = match\n const value = doubleQuotedValue ?? singleQuotedValue ?? unquotedValue ?? \"\"\n const safeAttribute = sanitizeAttribute(tagName, name, value)\n if (safeAttribute) attributes.push(safeAttribute)\n }\n\n if (tagName === \"a\" && attributes.some((attr) => attr.startsWith(\"href=\"))) {\n attributes.push('target=\"_blank\"', 'rel=\"noopener noreferrer\"')\n }\n\n return attributes.length ? ` ${attributes.join(\" \")}` : \"\"\n}\n\nfunction findTagEnd(html: string, startIndex: number): number {\n let quote: '\"' | \"'\" | null = null\n\n for (let i = startIndex + 1; i < html.length; i += 1) {\n const char = html[i]\n if (quote) {\n if (char === quote) quote = null\n continue\n }\n\n if (char === '\"' || char === \"'\") {\n quote = char\n continue\n }\n\n if (char === \">\") return i\n }\n\n return -1\n}\n\nfunction parseTag(rawTag: string): { closing: boolean; name: string; attributes: string } | null {\n const match = rawTag.match(/^<\\s*(\\/)?\\s*([A-Za-z][A-Za-z0-9:-]*)\\b([\\s\\S]*?)\\/?>$/)\n if (!match) return null\n\n return {\n closing: !!match[1],\n name: match[2].toLowerCase(),\n attributes: match[3] ?? \"\",\n }\n}\n\nfunction findDangerousClose(html: string, tagName: string, fromIndex: number): number {\n const closePattern = new RegExp(`</\\\\s*${tagName}\\\\s*>`, \"ig\")\n closePattern.lastIndex = fromIndex\n const match = closePattern.exec(html)\n return match ? closePattern.lastIndex : -1\n}\n\n/**\n * Conservative, deterministic sanitizer for user/email supplied HTML rendered by\n * design-system components. It keeps common email formatting tags while removing\n * executable tags, event handlers, unsafe inline styles, and unsafe URLs. This stays\n * dependency-free for the shared package and intentionally favors stripping\n * ambiguous email content over preserving every possible HTML feature.\n */\nexport function sanitizeHtml(html: string): string {\n let output = \"\"\n let cursor = 0\n\n while (cursor < html.length) {\n const tagStart = html.indexOf(\"<\", cursor)\n if (tagStart === -1) {\n output += html.slice(cursor)\n break\n }\n\n output += html.slice(cursor, tagStart)\n\n if (html.startsWith(\"<!--\", tagStart)) {\n const commentEnd = html.indexOf(\"-->\", tagStart + 4)\n cursor = commentEnd === -1 ? html.length : commentEnd + 3\n continue\n }\n\n const tagEnd = findTagEnd(html, tagStart)\n if (tagEnd === -1) {\n output += escapeHtml(html.slice(tagStart))\n break\n }\n\n const rawTag = html.slice(tagStart, tagEnd + 1)\n const parsed = parseTag(rawTag)\n if (!parsed) {\n cursor = tagEnd + 1\n continue\n }\n\n if (DANGEROUS_BLOCK_TAGS.has(parsed.name)) {\n const closeEnd = parsed.closing ? -1 : findDangerousClose(html, parsed.name, tagEnd + 1)\n cursor = closeEnd === -1 ? tagEnd + 1 : closeEnd\n continue\n }\n\n if (ALLOWED_TAGS.has(parsed.name)) {\n if (parsed.closing) {\n if (!VOID_TAGS.has(parsed.name)) output += `</${parsed.name}>`\n } else {\n output += `<${parsed.name}${sanitizeAttributes(parsed.name, parsed.attributes)}>`\n }\n }\n\n cursor = tagEnd + 1\n }\n\n return output\n}\n\nexport function htmlToTextSnippet(html: string, maxLength = 140): string {\n return sanitizeHtml(html)\n .replace(/<[^>]+>/g, \" \")\n .replace(/\\s+/g, \" \")\n .trim()\n .slice(0, maxLength)\n}\n"],"mappings":"AAAA,MAAM,uBAAuB,oBAAI,IAAI;AAAA,EACnC;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AACF,CAAC;AAED,MAAM,eAAe,oBAAI,IAAI;AAAA,EAC3B;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AACF,CAAC;AAED,MAAM,YAAY,oBAAI,IAAI,CAAC,MAAM,MAAM,KAAK,CAAC;AAC7C,MAAM,oBAAoB,oBAAI,IAAI,CAAC,cAAc,OAAO,QAAQ,QAAQ,OAAO,CAAC;AAChF,MAAM,qBAAqB,oBAAI,IAAI,CAAC,SAAS,UAAU,WAAW,MAAM,CAAC;AAEzE,SAAS,WAAW,OAAuB;AACzC,SAAO,MACJ,QAAQ,MAAM,OAAO,EACrB,QAAQ,MAAM,MAAM,EACpB,QAAQ,MAAM,MAAM;AACzB;AAEA,SAAS,gBAAgB,OAAuB;AAC9C,SAAO,WAAW,KAAK,EAAE,QAAQ,MAAM,QAAQ;AACjD;AAEA,SAAS,cAAc,OAAuB;AAC5C,SAAO,OAAO,UAAU,KAAK,KAAK,SAAS,KAAK,SAAS,UAAW,OAAO,cAAc,KAAK,IAAI;AACpG;AAEA,SAAS,mBAAmB,OAAuB;AACjD,QAAM,gBAAwC;AAAA,IAC5C,KAAK;AAAA,IACL,MAAM;AAAA,IACN,OAAO;AAAA,IACP,IAAI;AAAA,IACJ,IAAI;AAAA,IACJ,SAAS;AAAA,IACT,MAAM;AAAA,IACN,KAAK;AAAA,EACP;AAEA,MAAI,UAAU;AACd,WAAS,IAAI,GAAG,IAAI,GAAG,KAAK,GAAG;AAC7B,UAAM,OAAO,QACV,QAAQ,sBAAsB,CAAC,QAAQ,QAAgB;AACtD,YAAM,YAAY,OAAO,SAAS,KAAK,EAAE;AACzC,aAAO,cAAc,SAAS;AAAA,IAChC,CAAC,EACA,QAAQ,cAAc,CAAC,QAAQ,YAAoB;AAClD,YAAM,YAAY,OAAO,SAAS,SAAS,EAAE;AAC7C,aAAO,cAAc,SAAS;AAAA,IAChC,CAAC,EACA,QAAQ,gBAAgB,CAAC,OAAO,SAAc;AAvFrD;AAuFwD,iCAAc,KAAK,YAAY,CAAC,MAAhC,YAAqC;AAAA,KAAK;AAE9F,QAAI,SAAS,QAAS,QAAO;AAC7B,cAAU;AAAA,EACZ;AAEA,SAAO;AACT;AAEA,SAAS,UAAU,OAAwB;AACzC,QAAM,UAAU,mBAAmB,KAAK,EAAE,QAAQ,6BAA6B,EAAE,EAAE,KAAK;AACxF,MAAI,CAAC,QAAS,QAAO;AACrB,MAAI,QAAQ,WAAW,IAAI,EAAG,QAAO;AACrC,MAAI,QAAQ,WAAW,GAAG,KAAK,QAAQ,WAAW,GAAG,KAAK,QAAQ,WAAW,IAAI,KAAK,QAAQ,WAAW,KAAK,GAAG;AAC/G,WAAO;AAAA,EACT;AAEA,MAAI;AACF,WAAO,mBAAmB,IAAI,IAAI,IAAI,SAAS,uBAAuB,EAAE,QAAQ;AAAA,EAClF,SAAQ;AACN,WAAO;AAAA,EACT;AACF;AAEA,SAAS,kBAAkB,OAA8B;AACvD,QAAM,aAAa,MAChB,MAAM,KAAK,EACX,IAAI,CAAC,UAAU,MAAM,KAAK,CAAC,EAC3B,OAAO,CAAC,UAAU,mBAAmB,KAAK,KAAK,CAAC;AAEnD,SAAO,WAAW,SAAS,WAAW,KAAK,GAAG,IAAI;AACpD;AAEA,SAAS,kBAAkB,OAA8B;AACvD,QAAM,UAAU,MAAM,KAAK;AAC3B,MAAI,YAAY,KAAK,OAAO,EAAG,QAAO;AACtC,SAAO;AACT;AAEA,SAAS,iBAAiB,OAA8B;AACtD,QAAM,UAAU,MAAM,KAAK;AAC3B,MAAI,wCAAwC,KAAK,OAAO,EAAG,QAAO;AAClE,SAAO;AACT;AAEA,SAAS,kBAAkB,OAA8B;AACvD,QAAM,UAAU,MAAM,KAAK,EAAE,YAAY;AACzC,SAAO,YAAY,SAAS,YAAY,SAAS,YAAY,SAAS,UAAU;AAClF;AAEA,SAAS,iBAAiB,OAA8B;AACtD,QAAM,UAAU,MAAM,KAAK,EAAE,YAAY;AACzC,QAAM,QAAQ,QAAQ,MAAM,gCAAgC;AAC5D,MAAI,CAAC,MAAO,QAAO;AAEnB,QAAM,SAAS,OAAO,WAAW,MAAM,CAAC,CAAC;AACzC,QAAM,OAAO,MAAM,CAAC;AACpB,QAAM,YAAoC,EAAE,IAAI,IAAI,IAAI,GAAG,KAAK,GAAG,KAAK,IAAI;AAC5E,MAAI,CAAC,OAAO,SAAS,MAAM,KAAK,UAAU,KAAK,SAAS,UAAU,IAAI,EAAG,QAAO;AAChF,SAAO,GAAG,MAAM,GAAG,IAAI;AACzB;AAEA,SAAS,cAAc,OAA8B;AACnD,QAAM,eAAyB,CAAC;AAEhC,aAAW,kBAAkB,MAAM,MAAM,GAAG,GAAG;AAC7C,UAAM,iBAAiB,eAAe,QAAQ,GAAG;AACjD,QAAI,mBAAmB,GAAI;AAE3B,UAAM,WAAW,eAAe,MAAM,GAAG,cAAc,EAAE,KAAK,EAAE,YAAY;AAC5E,UAAM,WAAW,mBAAmB,eAAe,MAAM,iBAAiB,CAAC,CAAC,EAAE,KAAK,EAAE,YAAY;AACjG,QAAI,CAAC,YAAY,CAAC,YAAY,4BAA4B,KAAK,QAAQ,EAAG;AAE1E,QAAI,aAAa,qBAAqB,aAAa,WAAW,aAAa,QAAQ;AACjF,mBAAa,KAAK,mBAAmB,QAAQ,EAAE;AAC/C;AAAA,IACF;AAEA,QAAI,aAAa,aAAa;AAC5B,YAAM,WAAW,iBAAiB,QAAQ;AAC1C,UAAI,SAAU,cAAa,KAAK,cAAc,QAAQ,EAAE;AAAA,IAC1D;AAAA,EACF;AAEA,SAAO,aAAa,SAAS,aAAa,KAAK,IAAI,IAAI;AACzD;AAEA,SAAS,kBAAkB,SAAiB,MAAc,OAA8B;AACtF,QAAM,OAAO,KAAK,YAAY;AAE9B,MACE,KAAK,WAAW,IAAI,KACpB,SAAS,YACT,SAAS,gBACT,SAAS,gBACT,SAAS,SACT;AACA,WAAO;AAAA,EACT;AAEA,MAAI,SAAS,SAAS;AACpB,UAAM,YAAY,cAAc,KAAK;AACrC,WAAO,YAAY,UAAU,gBAAgB,SAAS,CAAC,MAAM;AAAA,EAC/D;AAEA,MAAI,SAAS,SAAS;AACpB,UAAM,gBAAgB,kBAAkB,KAAK;AAC7C,WAAO,gBAAgB,UAAU,gBAAgB,aAAa,CAAC,MAAM;AAAA,EACvE;AAEA,MAAI,SAAS,OAAO;AAClB,UAAM,gBAAgB,kBAAkB,KAAK;AAC7C,WAAO,gBAAgB,QAAQ,gBAAgB,aAAa,CAAC,MAAM;AAAA,EACrE;AAEA,MAAI,SAAS,QAAQ;AACnB,UAAM,eAAe,iBAAiB,KAAK;AAC3C,WAAO,eAAe,SAAS,gBAAgB,YAAY,CAAC,MAAM;AAAA,EACpE;AAEA,MAAI,kBAAkB,IAAI,IAAI,KAAK,KAAK,WAAW,OAAO,GAAG;AAC3D,WAAO,GAAG,IAAI,KAAK,gBAAgB,KAAK,CAAC;AAAA,EAC3C;AAEA,MAAI,YAAY,OAAO,SAAS,UAAU,UAAU,KAAK,GAAG;AAC1D,WAAO,SAAS,gBAAgB,KAAK,CAAC;AAAA,EACxC;AAEA,MAAI,YAAY,SAAS,SAAS,SAAS,UAAU,KAAK,GAAG;AAC3D,WAAO,QAAQ,gBAAgB,KAAK,CAAC;AAAA,EACvC;AAEA,MAAI,YAAY,SAAS,SAAS,OAAO;AACvC,WAAO,GAAG,IAAI,KAAK,gBAAgB,KAAK,CAAC;AAAA,EAC3C;AAEA,MAAI,YAAY,UAAU,SAAS,WAAW,SAAS,WAAW;AAChE,UAAM,gBAAgB,kBAAkB,KAAK;AAC7C,WAAO,gBAAgB,GAAG,IAAI,KAAK,gBAAgB,aAAa,CAAC,MAAM;AAAA,EACzE;AAEA,OAAK,YAAY,QAAQ,YAAY,UAAU,SAAS,aAAa,SAAS,YAAY;AACxF,WAAO,GAAG,IAAI,KAAK,gBAAgB,KAAK,CAAC;AAAA,EAC3C;AAEA,SAAO;AACT;AAEA,SAAS,mBAAmB,SAAiB,gBAAgB,IAAY;AA3OzE;AA4OE,QAAM,aAAuB,CAAC;AAC9B,QAAM,cAAc;AACpB,MAAI;AAEJ,UAAQ,QAAQ,YAAY,KAAK,aAAa,OAAO,MAAM;AACzD,UAAM,CAAC,EAAE,MAAM,mBAAmB,mBAAmB,aAAa,IAAI;AACtE,UAAM,SAAQ,2DAAqB,sBAArB,YAA0C,kBAA1C,YAA2D;AACzE,UAAM,gBAAgB,kBAAkB,SAAS,MAAM,KAAK;AAC5D,QAAI,cAAe,YAAW,KAAK,aAAa;AAAA,EAClD;AAEA,MAAI,YAAY,OAAO,WAAW,KAAK,CAAC,SAAS,KAAK,WAAW,OAAO,CAAC,GAAG;AAC1E,eAAW,KAAK,mBAAmB,2BAA2B;AAAA,EAChE;AAEA,SAAO,WAAW,SAAS,IAAI,WAAW,KAAK,GAAG,CAAC,KAAK;AAC1D;AAEA,SAAS,WAAW,MAAc,YAA4B;AAC5D,MAAI,QAA0B;AAE9B,WAAS,IAAI,aAAa,GAAG,IAAI,KAAK,QAAQ,KAAK,GAAG;AACpD,UAAM,OAAO,KAAK,CAAC;AACnB,QAAI,OAAO;AACT,UAAI,SAAS,MAAO,SAAQ;AAC5B;AAAA,IACF;AAEA,QAAI,SAAS,OAAO,SAAS,KAAK;AAChC,cAAQ;AACR;AAAA,IACF;AAEA,QAAI,SAAS,IAAK,QAAO;AAAA,EAC3B;AAEA,SAAO;AACT;AAEA,SAAS,SAAS,QAA+E;AAnRjG;AAoRE,QAAM,QAAQ,OAAO,MAAM,wDAAwD;AACnF,MAAI,CAAC,MAAO,QAAO;AAEnB,SAAO;AAAA,IACL,SAAS,CAAC,CAAC,MAAM,CAAC;AAAA,IAClB,MAAM,MAAM,CAAC,EAAE,YAAY;AAAA,IAC3B,aAAY,WAAM,CAAC,MAAP,YAAY;AAAA,EAC1B;AACF;AAEA,SAAS,mBAAmB,MAAc,SAAiB,WAA2B;AACpF,QAAM,eAAe,IAAI,OAAO,SAAS,OAAO,SAAS,IAAI;AAC7D,eAAa,YAAY;AACzB,QAAM,QAAQ,aAAa,KAAK,IAAI;AACpC,SAAO,QAAQ,aAAa,YAAY;AAC1C;AASO,SAAS,aAAa,MAAsB;AACjD,MAAI,SAAS;AACb,MAAI,SAAS;AAEb,SAAO,SAAS,KAAK,QAAQ;AAC3B,UAAM,WAAW,KAAK,QAAQ,KAAK,MAAM;AACzC,QAAI,aAAa,IAAI;AACnB,gBAAU,KAAK,MAAM,MAAM;AAC3B;AAAA,IACF;AAEA,cAAU,KAAK,MAAM,QAAQ,QAAQ;AAErC,QAAI,KAAK,WAAW,QAAQ,QAAQ,GAAG;AACrC,YAAM,aAAa,KAAK,QAAQ,OAAO,WAAW,CAAC;AACnD,eAAS,eAAe,KAAK,KAAK,SAAS,aAAa;AACxD;AAAA,IACF;AAEA,UAAM,SAAS,WAAW,MAAM,QAAQ;AACxC,QAAI,WAAW,IAAI;AACjB,gBAAU,WAAW,KAAK,MAAM,QAAQ,CAAC;AACzC;AAAA,IACF;AAEA,UAAM,SAAS,KAAK,MAAM,UAAU,SAAS,CAAC;AAC9C,UAAM,SAAS,SAAS,MAAM;AAC9B,QAAI,CAAC,QAAQ;AACX,eAAS,SAAS;AAClB;AAAA,IACF;AAEA,QAAI,qBAAqB,IAAI,OAAO,IAAI,GAAG;AACzC,YAAM,WAAW,OAAO,UAAU,KAAK,mBAAmB,MAAM,OAAO,MAAM,SAAS,CAAC;AACvF,eAAS,aAAa,KAAK,SAAS,IAAI;AACxC;AAAA,IACF;AAEA,QAAI,aAAa,IAAI,OAAO,IAAI,GAAG;AACjC,UAAI,OAAO,SAAS;AAClB,YAAI,CAAC,UAAU,IAAI,OAAO,IAAI,EAAG,WAAU,KAAK,OAAO,IAAI;AAAA,MAC7D,OAAO;AACL,kBAAU,IAAI,OAAO,IAAI,GAAG,mBAAmB,OAAO,MAAM,OAAO,UAAU,CAAC;AAAA,MAChF;AAAA,IACF;AAEA,aAAS,SAAS;AAAA,EACpB;AAEA,SAAO;AACT;AAEO,SAAS,kBAAkB,MAAc,YAAY,KAAa;AACvE,SAAO,aAAa,IAAI,EACrB,QAAQ,YAAY,GAAG,EACvB,QAAQ,QAAQ,GAAG,EACnB,KAAK,EACL,MAAM,GAAG,SAAS;AACvB;","names":[]}
|
package/package.json
CHANGED
|
@@ -7,6 +7,18 @@ import {
|
|
|
7
7
|
type ConvParticipant,
|
|
8
8
|
} from "../conversation-panel";
|
|
9
9
|
|
|
10
|
+
|
|
11
|
+
function expectNoVisibleEscapeArtifacts(value: string) {
|
|
12
|
+
expect(value).not.toMatch(/&#(?:x[0-9a-f]+|\d+);?/i);
|
|
13
|
+
expect(value).not.toContain("&");
|
|
14
|
+
expect(value).not.toContain("<");
|
|
15
|
+
expect(value).not.toContain(">");
|
|
16
|
+
expect(value).not.toContain(""");
|
|
17
|
+
expect(value).not.toContain('\\"');
|
|
18
|
+
expect(value).not.toContain("\\'");
|
|
19
|
+
expect(value).not.toContain("\\n");
|
|
20
|
+
}
|
|
21
|
+
|
|
10
22
|
const me: ConvParticipant = { name: "Dana Okafor", email: "dana@handled.ai" };
|
|
11
23
|
const priya: ConvParticipant = { name: "Priya Raman", email: "priya@northwind.io" };
|
|
12
24
|
|
|
@@ -86,6 +98,54 @@ describe("ConversationPanel", () => {
|
|
|
86
98
|
expect(screen.getByText("Draft copy")).toBeDefined();
|
|
87
99
|
});
|
|
88
100
|
|
|
101
|
+
|
|
102
|
+
it("uses the custom read-only reason and disables Open in Gmail when access is unavailable", () => {
|
|
103
|
+
const onOpenInGmail = vi.fn();
|
|
104
|
+
|
|
105
|
+
render(
|
|
106
|
+
<ConversationPanel
|
|
107
|
+
threads={[
|
|
108
|
+
thread({
|
|
109
|
+
canReply: false,
|
|
110
|
+
replyDisabledReason: "Only the case owner can open Gmail drafts for this case.",
|
|
111
|
+
openInGmailDisabled: true,
|
|
112
|
+
openInGmailDisabledReason: "Only the case owner can open this draft in Gmail.",
|
|
113
|
+
}),
|
|
114
|
+
]}
|
|
115
|
+
me={me}
|
|
116
|
+
onOpenInGmail={onOpenInGmail}
|
|
117
|
+
defaultOpenThreadId="t1"
|
|
118
|
+
/>,
|
|
119
|
+
);
|
|
120
|
+
|
|
121
|
+
expect(screen.getByText(/Only the case owner can open Gmail drafts for this case\./)).toBeDefined();
|
|
122
|
+
const button = screen.getByRole("button", {
|
|
123
|
+
name: "Open in Gmail: Only the case owner can open this draft in Gmail.",
|
|
124
|
+
});
|
|
125
|
+
expect(button).toHaveProperty("disabled", true);
|
|
126
|
+
|
|
127
|
+
fireEvent.click(button);
|
|
128
|
+
expect(onOpenInGmail).not.toHaveBeenCalled();
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it("renders Open in Gmail as a new-tab link when a URL is provided", () => {
|
|
132
|
+
render(
|
|
133
|
+
<ConversationPanel
|
|
134
|
+
threads={[
|
|
135
|
+
thread({
|
|
136
|
+
openInGmailUrl: "https://mail.google.com/mail/?authuser=dana%40handled.ai#drafts/msg-1",
|
|
137
|
+
}),
|
|
138
|
+
]}
|
|
139
|
+
me={me}
|
|
140
|
+
/>,
|
|
141
|
+
);
|
|
142
|
+
|
|
143
|
+
const link = screen.getByRole("link", { name: /Open in Gmail/ });
|
|
144
|
+
expect(link.getAttribute("href")).toBe("https://mail.google.com/mail/?authuser=dana%40handled.ai#drafts/msg-1");
|
|
145
|
+
expect(link.getAttribute("target")).toBe("_blank");
|
|
146
|
+
expect(link.getAttribute("rel")).toBe("noopener noreferrer");
|
|
147
|
+
});
|
|
148
|
+
|
|
89
149
|
it("auto-opens the first responded thread and renders its newest message as HTML", () => {
|
|
90
150
|
const { container } = render(<ConversationPanel threads={[thread()]} me={me} />);
|
|
91
151
|
// newest (inbound) message expanded -> its anchor is in the DOM
|
|
@@ -148,14 +208,14 @@ describe("ConversationPanel", () => {
|
|
|
148
208
|
|
|
149
209
|
const message = container.querySelector('[data-slot="conv-message"]')!;
|
|
150
210
|
expect(message.querySelector('[data-slot="conv-message-body"]')?.textContent).toContain("The updated invoice is attached.");
|
|
151
|
-
expect(message.querySelector('[data-slot="
|
|
211
|
+
expect(message.querySelector('[data-slot="email-body-details"]')).toBeNull();
|
|
152
212
|
expect(message.querySelector('[data-slot="conv-message-body"]')?.textContent).not.toContain("Confidentiality Notice");
|
|
153
213
|
|
|
154
|
-
fireEvent.click(screen.
|
|
214
|
+
fireEvent.click(screen.getByRole("button", { name: "•••" }));
|
|
155
215
|
|
|
156
|
-
expect(screen.
|
|
157
|
-
expect(message.querySelector('[data-slot="
|
|
158
|
-
expect(message.querySelector('[data-slot="
|
|
216
|
+
expect(screen.getByRole("button", { name: "•••" })).toBeDefined();
|
|
217
|
+
expect(message.querySelector('[data-slot="email-body-details"]')?.textContent).toContain("Priya Raman");
|
|
218
|
+
expect(message.querySelector('[data-slot="email-body-details"]')?.textContent).toContain("Confidentiality Notice");
|
|
159
219
|
});
|
|
160
220
|
|
|
161
221
|
it("collapses trailing plain-text signatures while preserving line boundaries", () => {
|
|
@@ -176,14 +236,13 @@ describe("ConversationPanel", () => {
|
|
|
176
236
|
|
|
177
237
|
const message = container.querySelector('[data-slot="conv-message"]')!;
|
|
178
238
|
expect(message.querySelector('[data-slot="conv-message-body"]')?.textContent).toContain("I can meet tomorrow at 2pm.");
|
|
179
|
-
expect(message.querySelector('[data-slot="
|
|
239
|
+
expect(message.querySelector('[data-slot="email-body-details"]')).toBeNull();
|
|
180
240
|
|
|
181
|
-
fireEvent.click(screen.
|
|
241
|
+
fireEvent.click(screen.getByRole("button", { name: "•••" }));
|
|
182
242
|
|
|
183
|
-
const details = message.querySelector('[data-slot="
|
|
243
|
+
const details = message.querySelector('[data-slot="email-body-details"]')!;
|
|
184
244
|
expect(details.textContent).toContain("Best,");
|
|
185
245
|
expect(details.textContent).toContain("VP Sales, Northwind");
|
|
186
|
-
expect(message.querySelector('[data-slot="conv-message-body"]')?.textContent?.endsWith("2pm.\n\n")).toBe(true);
|
|
187
246
|
expect(details.textContent?.startsWith("Best,")).toBe(true);
|
|
188
247
|
});
|
|
189
248
|
|
|
@@ -205,7 +264,7 @@ describe("ConversationPanel", () => {
|
|
|
205
264
|
const message = container.querySelector('[data-slot="conv-message"]')!;
|
|
206
265
|
|
|
207
266
|
expect(message.querySelector('[data-slot="conv-message-body"]')?.textContent).toContain("Thanks for sending this over");
|
|
208
|
-
expect(screen.
|
|
267
|
+
expect(screen.queryByRole("button", { name: "•••" })).toBeNull();
|
|
209
268
|
});
|
|
210
269
|
|
|
211
270
|
it("keeps trailing thanks comma sentence body visible", () => {
|
|
@@ -226,7 +285,7 @@ describe("ConversationPanel", () => {
|
|
|
226
285
|
const message = container.querySelector('[data-slot="conv-message"]')!;
|
|
227
286
|
|
|
228
287
|
expect(message.querySelector('[data-slot="conv-message-body"]')?.textContent).toContain("Thanks, this helps us decide next steps.");
|
|
229
|
-
expect(screen.
|
|
288
|
+
expect(screen.queryByRole("button", { name: "•••" })).toBeNull();
|
|
230
289
|
});
|
|
231
290
|
|
|
232
291
|
it("keeps trailing thank-you comma sentence body visible", () => {
|
|
@@ -247,7 +306,7 @@ describe("ConversationPanel", () => {
|
|
|
247
306
|
const message = container.querySelector('[data-slot="conv-message"]')!;
|
|
248
307
|
|
|
249
308
|
expect(message.querySelector('[data-slot="conv-message-body"]')?.textContent).toContain("Thank you, that clarifies the renewal plan.");
|
|
250
|
-
expect(screen.
|
|
309
|
+
expect(screen.queryByRole("button", { name: "•••" })).toBeNull();
|
|
251
310
|
});
|
|
252
311
|
|
|
253
312
|
it("keeps command-like double-dash plain-text lines visible", () => {
|
|
@@ -269,7 +328,7 @@ describe("ConversationPanel", () => {
|
|
|
269
328
|
|
|
270
329
|
expect(message.querySelector('[data-slot="conv-message-body"]')?.textContent).toContain("-- retry failed imports");
|
|
271
330
|
expect(message.querySelector('[data-slot="conv-message-body"]')?.textContent).toContain("-- skip archived records");
|
|
272
|
-
expect(screen.
|
|
331
|
+
expect(screen.queryByRole("button", { name: "•••" })).toBeNull();
|
|
273
332
|
});
|
|
274
333
|
|
|
275
334
|
it("keeps command-like double-dash HTML blocks visible", () => {
|
|
@@ -291,7 +350,7 @@ describe("ConversationPanel", () => {
|
|
|
291
350
|
|
|
292
351
|
expect(message.querySelector('[data-slot="conv-message-body"]')?.textContent).toContain("-- retry failed imports");
|
|
293
352
|
expect(message.querySelector('[data-slot="conv-message-body"]')?.textContent).toContain("-- skip archived records");
|
|
294
|
-
expect(screen.
|
|
353
|
+
expect(screen.queryByRole("button", { name: "•••" })).toBeNull();
|
|
295
354
|
});
|
|
296
355
|
|
|
297
356
|
it("collapses standard double-dash signature separator with sender details", () => {
|
|
@@ -314,10 +373,10 @@ describe("ConversationPanel", () => {
|
|
|
314
373
|
expect(message.querySelector('[data-slot="conv-message-body"]')?.textContent).toContain("The import plan looks good.");
|
|
315
374
|
expect(message.querySelector('[data-slot="conv-message-body"]')?.textContent).not.toContain("Priya Raman");
|
|
316
375
|
|
|
317
|
-
fireEvent.click(screen.
|
|
376
|
+
fireEvent.click(screen.getByRole("button", { name: "•••" }));
|
|
318
377
|
|
|
319
|
-
expect(message.querySelector('[data-slot="
|
|
320
|
-
expect(message.querySelector('[data-slot="
|
|
378
|
+
expect(message.querySelector('[data-slot="email-body-details"]')?.textContent).toContain("--");
|
|
379
|
+
expect(message.querySelector('[data-slot="email-body-details"]')?.textContent).toContain("Priya Raman");
|
|
321
380
|
});
|
|
322
381
|
|
|
323
382
|
it("does not split raw HTML into invalid partial tags when collapsing details", () => {
|
|
@@ -339,14 +398,14 @@ describe("ConversationPanel", () => {
|
|
|
339
398
|
|
|
340
399
|
const link = container.querySelector('a[href="https://example.com/report"]');
|
|
341
400
|
expect(link?.textContent).toBe("the full report");
|
|
342
|
-
expect(container.querySelector('[data-slot="
|
|
401
|
+
expect(container.querySelector('[data-slot="email-body-details"]')).toBeNull();
|
|
343
402
|
|
|
344
403
|
const message = container.querySelector('[data-slot="conv-message"]')!;
|
|
345
404
|
expect(message.querySelector('[data-slot="conv-message-body"]')?.innerHTML).toContain("<strong>the full report</strong>");
|
|
346
405
|
expect(message.querySelector('[data-slot="conv-message-body"]')?.innerHTML).not.toContain("</a></strong>");
|
|
347
406
|
|
|
348
|
-
fireEvent.click(screen.
|
|
349
|
-
expect(container.querySelector('[data-slot="
|
|
407
|
+
fireEvent.click(screen.getByRole("button", { name: "•••" }));
|
|
408
|
+
expect(container.querySelector('[data-slot="email-body-details"]')?.textContent).toContain("Priya Raman");
|
|
350
409
|
expect(container.querySelector('[data-slot="conv-message"]')?.querySelectorAll("p").length).toBeGreaterThanOrEqual(4);
|
|
351
410
|
});
|
|
352
411
|
|
|
@@ -371,9 +430,9 @@ describe("ConversationPanel", () => {
|
|
|
371
430
|
|
|
372
431
|
render(<ConversationPanel threads={[quotedThread]} me={me} />);
|
|
373
432
|
|
|
374
|
-
fireEvent.click(screen.
|
|
433
|
+
fireEvent.click(screen.getByTitle("Show historical details"));
|
|
375
434
|
|
|
376
|
-
expect(document.querySelector('[data-slot="
|
|
435
|
+
expect(document.querySelector('[data-slot="email-body-details"]')?.textContent).toContain("Priya Raman");
|
|
377
436
|
expect(screen.queryByText("Prior hidden message")).toBeNull();
|
|
378
437
|
|
|
379
438
|
fireEvent.click(screen.getByTitle("Show quoted text"));
|
|
@@ -630,4 +689,153 @@ describe("ConversationPanel", () => {
|
|
|
630
689
|
expect(screen.getByText("Local draft preview")).toBeDefined();
|
|
631
690
|
expect(screen.getByText(/Local draft preview only/i)).toBeDefined();
|
|
632
691
|
});
|
|
692
|
+
|
|
693
|
+
|
|
694
|
+
it("renders thread messages ascending by raw chronological timestamp even when input is out of order", () => {
|
|
695
|
+
const outOfOrder = thread({
|
|
696
|
+
messages: [
|
|
697
|
+
{
|
|
698
|
+
id: "newer-inbound",
|
|
699
|
+
direction: "inbound",
|
|
700
|
+
from: priya,
|
|
701
|
+
to: me,
|
|
702
|
+
date: "10:00 AM",
|
|
703
|
+
receivedAt: "2026-06-08T10:00:00.000Z",
|
|
704
|
+
body: "Second chronological reply",
|
|
705
|
+
},
|
|
706
|
+
{
|
|
707
|
+
id: "older-outbound",
|
|
708
|
+
direction: "outbound",
|
|
709
|
+
from: me,
|
|
710
|
+
to: priya,
|
|
711
|
+
date: "9:00 AM",
|
|
712
|
+
sentAt: "2026-06-08T09:00:00.000Z",
|
|
713
|
+
body: "First chronological outbound",
|
|
714
|
+
},
|
|
715
|
+
{
|
|
716
|
+
id: "newest-inbound-fallback",
|
|
717
|
+
direction: "inbound",
|
|
718
|
+
from: priya,
|
|
719
|
+
to: me,
|
|
720
|
+
date: "11:00 AM",
|
|
721
|
+
internalDate: "1780916400000",
|
|
722
|
+
body: "Third chronological Gmail fallback",
|
|
723
|
+
},
|
|
724
|
+
],
|
|
725
|
+
});
|
|
726
|
+
|
|
727
|
+
const { container } = render(<ConversationPanel threads={[outOfOrder]} me={me} />);
|
|
728
|
+
const rows = Array.from(container.querySelectorAll('[data-slot="conv-message-collapsed"], [data-slot="conv-message"]'));
|
|
729
|
+
|
|
730
|
+
expect(rows).toHaveLength(3);
|
|
731
|
+
expect(rows[0].textContent).toContain("First chronological outbound");
|
|
732
|
+
expect(rows[1].textContent).toContain("Second chronological reply");
|
|
733
|
+
expect(rows[2].textContent).toContain("Third chronological Gmail fallback");
|
|
734
|
+
expect(rows[2].getAttribute("data-slot")).toBe("conv-message");
|
|
735
|
+
expect(container.textContent).toContain("Priya Raman · Priya: Third chronological Gmail fallback");
|
|
736
|
+
});
|
|
737
|
+
|
|
738
|
+
it("preserves stable input order when raw timestamps are invalid or missing", () => {
|
|
739
|
+
const invalidTimestampThread = thread({
|
|
740
|
+
messages: [
|
|
741
|
+
{
|
|
742
|
+
id: "display-newer-but-invalid",
|
|
743
|
+
direction: "inbound",
|
|
744
|
+
from: priya,
|
|
745
|
+
to: me,
|
|
746
|
+
date: "2099-01-01T00:00:00.000Z",
|
|
747
|
+
receivedAt: "not-a-date",
|
|
748
|
+
body: "Input order first invalid timestamp",
|
|
749
|
+
},
|
|
750
|
+
{
|
|
751
|
+
id: "missing-raw-timestamp",
|
|
752
|
+
direction: "outbound",
|
|
753
|
+
from: me,
|
|
754
|
+
to: priya,
|
|
755
|
+
date: "Jan 1",
|
|
756
|
+
body: "Input order second missing timestamp",
|
|
757
|
+
},
|
|
758
|
+
{
|
|
759
|
+
id: "display-older-but-invalid",
|
|
760
|
+
direction: "inbound",
|
|
761
|
+
from: priya,
|
|
762
|
+
to: me,
|
|
763
|
+
date: "1999-01-01T00:00:00.000Z",
|
|
764
|
+
rfc822Date: "definitely invalid",
|
|
765
|
+
body: "Input order third invalid timestamp",
|
|
766
|
+
},
|
|
767
|
+
],
|
|
768
|
+
});
|
|
769
|
+
|
|
770
|
+
const { container } = render(<ConversationPanel threads={[invalidTimestampThread]} me={me} />);
|
|
771
|
+
const rows = Array.from(container.querySelectorAll('[data-slot="conv-message-collapsed"], [data-slot="conv-message"]'));
|
|
772
|
+
|
|
773
|
+
expect(rows).toHaveLength(3);
|
|
774
|
+
expect(rows[0].textContent).toContain("Input order first invalid timestamp");
|
|
775
|
+
expect(rows[1].textContent).toContain("Input order second missing timestamp");
|
|
776
|
+
expect(rows[2].textContent).toContain("Input order third invalid timestamp");
|
|
777
|
+
expect(rows[2].getAttribute("data-slot")).toBe("conv-message");
|
|
778
|
+
});
|
|
779
|
+
|
|
780
|
+
it("uses shared rendering for decoded snippets, full body/signature, one quote expander, and outbound identity", () => {
|
|
781
|
+
const owner: ConvParticipant = { name: "Dana Viewer", email: "viewer@handled.ai" };
|
|
782
|
+
const actualSender: ConvParticipant = { name: "Alex & Sender", email: "alex.sender@example.com" };
|
|
783
|
+
const customer: ConvParticipant = { name: "Jane & Team", email: "jane@example.com" };
|
|
784
|
+
const richThread = thread({
|
|
785
|
+
contact: customer,
|
|
786
|
+
messages: [
|
|
787
|
+
{
|
|
788
|
+
id: "m1",
|
|
789
|
+
direction: "outbound",
|
|
790
|
+
from: actualSender,
|
|
791
|
+
to: customer,
|
|
792
|
+
date: "Jun 8",
|
|
793
|
+
bodyHtml:
|
|
794
|
+
"<p>Hello Jane & team — it's ready.</p><p>Thanks,</p><p>Alex Sender</p><p>Handled<sup>AI</sup></p>",
|
|
795
|
+
quoted: {
|
|
796
|
+
attr: "On Monday, Jane & Team wrote:",
|
|
797
|
+
html: '<blockquote class="gmail_quote"><p>Prior & decoded history</p></blockquote>',
|
|
798
|
+
},
|
|
799
|
+
},
|
|
800
|
+
{
|
|
801
|
+
id: "m2",
|
|
802
|
+
direction: "inbound",
|
|
803
|
+
from: customer,
|
|
804
|
+
to: actualSender,
|
|
805
|
+
date: "Today",
|
|
806
|
+
body: "Looks good & approved.\n\n--\nJane Team\nCEO\njane@example.com",
|
|
807
|
+
},
|
|
808
|
+
],
|
|
809
|
+
});
|
|
810
|
+
|
|
811
|
+
const { container } = render(<ConversationPanel threads={[richThread]} me={owner} />);
|
|
812
|
+
|
|
813
|
+
expect(container.textContent).toContain("Jane & Team · Jane: Looks good & approved.");
|
|
814
|
+
expectNoVisibleEscapeArtifacts(container.textContent ?? "");
|
|
815
|
+
|
|
816
|
+
fireEvent.click(container.querySelector('[data-slot="conv-message"] > button')!);
|
|
817
|
+
const collapsedRows = Array.from(container.querySelectorAll('[data-slot="conv-message-collapsed"]'));
|
|
818
|
+
const outboundRow = collapsedRows.find((row) => row.textContent?.includes("Alex · Hello Jane & team — it's ready."));
|
|
819
|
+
expect(outboundRow).toBeTruthy();
|
|
820
|
+
expect(outboundRow?.textContent).not.toContain("Dana Viewer");
|
|
821
|
+
|
|
822
|
+
fireEvent.click(outboundRow!);
|
|
823
|
+
const message = container.querySelector('[data-slot="conv-message"]')!;
|
|
824
|
+
expect(message.textContent).toContain("Alex & Sender");
|
|
825
|
+
expect(message.textContent).not.toContain("Dana Viewer");
|
|
826
|
+
expect(message.textContent).not.toContain("HandledAI");
|
|
827
|
+
expect(screen.getByTitle("Show historical details")).toBeTruthy();
|
|
828
|
+
expect(screen.getAllByRole("button", { name: "•••" }).length).toBe(2);
|
|
829
|
+
|
|
830
|
+
fireEvent.click(screen.getAllByRole("button", { name: "•••" })[0]);
|
|
831
|
+
expect(container.querySelector("sup")?.textContent).toBe("AI");
|
|
832
|
+
expect(message.textContent).toContain("HandledAI");
|
|
833
|
+
expect(screen.queryByText("Prior & decoded history")).toBeNull();
|
|
834
|
+
|
|
835
|
+
fireEvent.click(screen.getByTitle("Show quoted text"));
|
|
836
|
+
expect(screen.getByText("Prior & decoded history")).toBeTruthy();
|
|
837
|
+
expect(screen.getAllByRole("button", { name: "•••" }).length).toBe(2);
|
|
838
|
+
expectNoVisibleEscapeArtifacts(container.textContent ?? "");
|
|
839
|
+
});
|
|
840
|
+
|
|
633
841
|
});
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import React from "react"
|
|
2
|
+
import { cleanup, fireEvent, render, screen } from "@testing-library/react"
|
|
3
|
+
import { afterEach, describe, expect, it } from "vitest"
|
|
4
|
+
|
|
5
|
+
import { EmailBody } from "../email-body"
|
|
6
|
+
|
|
7
|
+
function expectNoVisibleEscapeArtifacts(value: string) {
|
|
8
|
+
expect(value).not.toMatch(/&#(?:x[0-9a-f]+|\d+);?/i)
|
|
9
|
+
expect(value).not.toContain("&")
|
|
10
|
+
expect(value).not.toContain("<")
|
|
11
|
+
expect(value).not.toContain(">")
|
|
12
|
+
expect(value).not.toContain(""")
|
|
13
|
+
expect(value).not.toContain("\\n")
|
|
14
|
+
expect(value).not.toContain('\\"')
|
|
15
|
+
expect(value).not.toContain("\\'")
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
afterEach(() => {
|
|
19
|
+
cleanup()
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
describe("EmailBody", () => {
|
|
23
|
+
it("sanitizes HTML at render time while preserving superscript signature fidelity", () => {
|
|
24
|
+
const { container } = render(
|
|
25
|
+
<EmailBody
|
|
26
|
+
variant="preview"
|
|
27
|
+
html={'<p>Hello & welcome 'team'</p><script>alert(1)</script>'}
|
|
28
|
+
detailsHtml={
|
|
29
|
+
'<div class="gmail_signature"><table><tbody><tr><td><img src="https://example.com/logo.png" width="100" height="40" alt="Logo"><sup>TM</sup><span style="vertical-align: super; font-size: 9px; position: fixed">1</span><a href="javascript:alert(1)" onclick="alert(1)">bad</a></td></tr></tbody></table></div>'
|
|
30
|
+
}
|
|
31
|
+
/>,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
expect(screen.getByText("Hello & welcome 'team'")).toBeTruthy()
|
|
35
|
+
expect(container.querySelector("script")).toBeNull()
|
|
36
|
+
expect(container.querySelector("sup")?.textContent).toBe("TM")
|
|
37
|
+
expect(container.querySelector("img")?.getAttribute("width")).toBe("100")
|
|
38
|
+
expect(container.querySelector("span")?.getAttribute("style")).toBe("vertical-align: super; font-size: 9px")
|
|
39
|
+
expect(container.querySelector("a")?.hasAttribute("href")).toBe(false)
|
|
40
|
+
expect(container.innerHTML).not.toContain("onclick")
|
|
41
|
+
expectNoVisibleEscapeArtifacts(container.textContent ?? "")
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
it("collapses details with an ellipsis toggle only when collapseDetails is enabled", () => {
|
|
45
|
+
const { container } = render(
|
|
46
|
+
<EmailBody
|
|
47
|
+
html="<p>Main body</p><p>Thanks,</p><p>Jane Doe</p><p>VP Sales</p>"
|
|
48
|
+
collapseDetails
|
|
49
|
+
/>,
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
expect(screen.getByText("Main body")).toBeTruthy()
|
|
53
|
+
expect(screen.queryByText("Jane Doe")).toBeNull()
|
|
54
|
+
expect(screen.getByRole("button", { name: "•••" }).getAttribute("aria-expanded")).toBe("false")
|
|
55
|
+
|
|
56
|
+
fireEvent.click(screen.getByRole("button", { name: "•••" }))
|
|
57
|
+
|
|
58
|
+
expect(container.querySelector('[data-slot="email-body-details"]')?.textContent).toContain("Jane Doe")
|
|
59
|
+
expect(container.querySelector('[data-slot="email-body-details"]')?.textContent).toContain("VP Sales")
|
|
60
|
+
expect(screen.getByRole("button", { name: "•••" }).getAttribute("aria-expanded")).toBe("true")
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
it("shows details by default when collapse is disabled", () => {
|
|
64
|
+
render(<EmailBody text="Please review." detailsText="Confidentiality Notice: private." collapseDetails={false} />)
|
|
65
|
+
|
|
66
|
+
expect(screen.queryByRole("button", { name: "•••" })).toBeNull()
|
|
67
|
+
expect(screen.getByText("Confidentiality Notice: private.")).toBeTruthy()
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
it("honors defaultDetailsOpen for collapsed history", () => {
|
|
71
|
+
render(<EmailBody text="Please review.\n\nBest,\nJane Doe\nVP Sales" collapseDetails defaultDetailsOpen />)
|
|
72
|
+
|
|
73
|
+
expect(document.querySelector('[data-slot="email-body-details"]')?.textContent).toContain("Jane Doe")
|
|
74
|
+
expect(screen.getByRole("button", { name: "•••" }).getAttribute("aria-expanded")).toBe("true")
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
it("decodes plain text bodies without visible escape artifacts", () => {
|
|
78
|
+
const { container } = render(<EmailBody text={'"Line one & two\\nLine 'three'"'} />)
|
|
79
|
+
|
|
80
|
+
expect(container.textContent).toContain("Line one & two\nLine 'three'")
|
|
81
|
+
expectNoVisibleEscapeArtifacts(container.textContent ?? "")
|
|
82
|
+
})
|
|
83
|
+
})
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest"
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
decodeEmailDisplayText,
|
|
5
|
+
emailBodySnippet,
|
|
6
|
+
formatAddressList,
|
|
7
|
+
formatEmailTimestamp,
|
|
8
|
+
normalizeEmailSender,
|
|
9
|
+
splitEmailHtmlForDisplay,
|
|
10
|
+
splitEmailTextForDisplay,
|
|
11
|
+
} from "../email-display-helpers"
|
|
12
|
+
|
|
13
|
+
function expectNoVisibleEscapeArtifacts(value: string) {
|
|
14
|
+
expect(value).not.toMatch(/&#(?:x[0-9a-f]+|\d+);?/i)
|
|
15
|
+
expect(value).not.toContain("&")
|
|
16
|
+
expect(value).not.toContain("<")
|
|
17
|
+
expect(value).not.toContain(">")
|
|
18
|
+
expect(value).not.toContain(""")
|
|
19
|
+
expect(value).not.toContain("\\n")
|
|
20
|
+
expect(value).not.toContain('\\"')
|
|
21
|
+
expect(value).not.toContain("\\'")
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
describe("email display helpers", () => {
|
|
25
|
+
it("decodes display text without visible escape artifacts", () => {
|
|
26
|
+
const decoded = decodeEmailDisplayText('"Hi Cory & Jane's team\\nSay \\\"hello\\\""')
|
|
27
|
+
|
|
28
|
+
expect(decoded).toBe('Hi Cory & Jane\'s team\nSay "hello"')
|
|
29
|
+
expectNoVisibleEscapeArtifacts(decoded)
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
it("dedupes combined sender fields into clean name and bare email", () => {
|
|
33
|
+
expect(normalizeEmailSender({ name: "Jane Doe <jane@example.com>", email: "Jane Doe <jane@example.com>" })).toEqual({
|
|
34
|
+
name: "Jane Doe",
|
|
35
|
+
email: "jane@example.com",
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
expect(normalizeEmailSender({ name: "jane@example.com", email: "jane@example.com", fallbackName: "Fallback" })).toEqual({
|
|
39
|
+
name: "jane@example.com",
|
|
40
|
+
email: "jane@example.com",
|
|
41
|
+
})
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
it("formats address lists without inventing names", () => {
|
|
45
|
+
expect(formatAddressList('"Jane Doe" <jane@example.com>, alex@example.com, Support & Success <support@example.com>')).toBe(
|
|
46
|
+
"Jane Doe <jane@example.com>, alex@example.com, Support & Success <support@example.com>",
|
|
47
|
+
)
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
it("formats timestamps deterministically and omits invalid dates", () => {
|
|
51
|
+
expect(formatEmailTimestamp("2026-06-08T20:45:00.000Z")).toBe("Jun 8, 2026, 8:45 PM")
|
|
52
|
+
expect(formatEmailTimestamp("not-a-date")).toBeNull()
|
|
53
|
+
expect(formatEmailTimestamp(null)).toBeNull()
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
it("splits HTML signatures, Gmail quotes, and disclaimers while preserving formatting", () => {
|
|
57
|
+
const disclaimerSplit = splitEmailHtmlForDisplay("<p>Please review.</p><p>Confidentiality Notice: this message is private.</p>")
|
|
58
|
+
expect(disclaimerSplit.bodyHtml).toContain("Please review")
|
|
59
|
+
expect(disclaimerSplit.detailsHtml).toContain("Confidentiality Notice")
|
|
60
|
+
|
|
61
|
+
const split = splitEmailHtmlForDisplay(
|
|
62
|
+
'<p>Hi Dana & team,</p><p>The update looks good.</p><div class="gmail_signature"><table><tbody><tr><td><sup>TM</sup> Jane</td></tr></tbody></table></div><blockquote class="gmail_quote"><div>On Monday, Dana wrote:</div><p>Old note</p></blockquote>',
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
expect(split.bodyHtml).toContain("The update looks good")
|
|
66
|
+
expect(split.bodyHtml).not.toContain("gmail_signature")
|
|
67
|
+
expect(split.detailsHtml).toContain('class="gmail_signature"')
|
|
68
|
+
expect(split.detailsHtml).toContain("<sup>TM</sup>")
|
|
69
|
+
expect(split.detailsHtml).toContain('class="gmail_quote"')
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it("splits plain text signatures while preserving line boundaries", () => {
|
|
73
|
+
const split = splitEmailTextForDisplay("Hi Dana,\n\nLooks good.\n\n-- \nJane Doe\nVP Sales\njane@example.com")
|
|
74
|
+
|
|
75
|
+
expect(split.bodyText).toBe("Hi Dana,\n\nLooks good.\n\n")
|
|
76
|
+
expect(split.detailsText).toBe("-- \nJane Doe\nVP Sales\njane@example.com")
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
it("builds decoded snippets from visible body only", () => {
|
|
80
|
+
const snippet = emailBodySnippet(
|
|
81
|
+
{
|
|
82
|
+
bodyHtml:
|
|
83
|
+
'<p>Hello & welcome 'Cory'</p><p>Thanks,</p><p>Jane Doe</p><p>VP Sales</p><p>jane@example.com</p>',
|
|
84
|
+
},
|
|
85
|
+
80,
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
expect(snippet).toBe("Hello & welcome 'Cory'")
|
|
89
|
+
expectNoVisibleEscapeArtifacts(snippet)
|
|
90
|
+
})
|
|
91
|
+
})
|