| 1 | import { describe, expect, it } from "vite-plus/test"; |
| 2 | |
| 3 | import { |
| 4 | COMPOSER_CONTEXT_CLIPBOARD_MIME, |
| 5 | decodeComposerContextFragment, |
| 6 | encodeComposerContextFragment, |
| 7 | encodeComposerContextClipboardHtml, |
| 8 | decodeComposerContextClipboardHtml, |
| 9 | } from "./composerContextClipboard.ts"; |
| 10 | |
| 11 | describe("composerContextClipboard", () => { |
| 12 | it("preserves rich HTML while carrying context metadata", () => { |
| 13 | const fragment = encodeComposerContextFragment({ |
| 14 | version: 1, |
| 15 | source: { environmentId: "env" as never }, |
| 16 | records: [], |
| 17 | })!; |
| 18 | const rich = '<p><strong>Important</strong> <a href="https://example.com">link</a></p>'; |
| 19 | const html = encodeComposerContextClipboardHtml("Important link", fragment, rich); |
| 20 | expect(html).toContain(rich); |
| 21 | expect(decodeComposerContextClipboardHtml(html)).toEqual(JSON.parse(fragment)); |
| 22 | }); |
| 23 | it("round-trips selections larger than two million characters", () => { |
| 24 | const fragment = { |
| 25 | version: 1 as const, |
| 26 | source: { environmentId: "env" as never }, |
| 27 | records: Array.from({ length: 32 }, (_, index) => ({ |
| 28 | version: 1 as const, |
| 29 | contextId: `terminal-${index}` as never, |
| 30 | kind: "terminal" as const, |
| 31 | label: "Build", |
| 32 | terminalId: "build", |
| 33 | terminalLabel: "Build", |
| 34 | lineStart: 1, |
| 35 | lineEnd: 1, |
| 36 | text: "x".repeat(64_000), |
| 37 | })), |
| 38 | }; |
| 39 | const encoded = encodeComposerContextFragment(fragment); |
| 40 | expect(encoded?.length).toBeGreaterThan(2_000_000); |
| 41 | expect(decodeComposerContextFragment(encoded)).toEqual(fragment); |
| 42 | expect( |
| 43 | encodeComposerContextFragment({ |
| 44 | ...fragment, |
| 45 | records: Array.from({ length: 200 }, () => ({ |
| 46 | ...fragment.records[0]!, |
| 47 | text: "\u0000".repeat(64_000), |
| 48 | })), |
| 49 | }), |
| 50 | ).toBeNull(); |
| 51 | }); |
| 52 | it("round-trips native/browser HTML without interpreting captured content as markup", () => { |
| 53 | const raw = JSON.stringify({ |
| 54 | version: 1, |
| 55 | source: { environmentId: "env-1" }, |
| 56 | records: [ |
| 57 | { |
| 58 | version: 1, |
| 59 | kind: "terminal", |
| 60 | contextId: "terminal", |
| 61 | label: 'Build "failure"', |
| 62 | terminalId: "main", |
| 63 | terminalLabel: "Main", |
| 64 | lineStart: 1, |
| 65 | lineEnd: 1, |
| 66 | text: "<script> & café", |
| 67 | }, |
| 68 | ], |
| 69 | }); |
| 70 | const html = encodeComposerContextClipboardHtml("<script> & café", raw); |
| 71 | expect(html).toContain("&lt;script&gt; &amp; café"); |
| 72 | expect(html).not.toContain("<script>"); |
| 73 | expect(decodeComposerContextClipboardHtml(html)).toEqual(JSON.parse(raw)); |
| 74 | expect( |
| 75 | decodeComposerContextClipboardHtml('<pre data-t3-context-fragment="%ZZ">bad</pre>'), |
| 76 | ).toBeNull(); |
| 77 | expect(decodeComposerContextClipboardHtml("<p>Ordinary clipboard</p>")).toBeNull(); |
| 78 | }); |
| 79 | it("accepts a large non-ASCII fragment through the HTML attribute", () => { |
| 80 | // `encodeURIComponent` expands each CJK code unit to nine characters, so a fragment well |
| 81 | // inside the character limit still produces a much longer attribute. |
| 82 | const raw = JSON.stringify({ |
| 83 | version: 1, |
| 84 | source: { environmentId: "env-1" }, |
| 85 | records: [ |
| 86 | { |
| 87 | version: 1, |
| 88 | kind: "terminal", |
| 89 | contextId: "terminal", |
| 90 | label: "ビルド", |
| 91 | terminalId: "main", |
| 92 | terminalLabel: "Main", |
| 93 | lineStart: 1, |
| 94 | lineEnd: 1, |
| 95 | text: "日本語".repeat(21_000), |
| 96 | }, |
| 97 | ], |
| 98 | }); |
| 99 | |
| 100 | const html = encodeComposerContextClipboardHtml("output", raw); |
| 101 | // The attribute runs to nearly nine characters per code unit. A bound derived from the |
| 102 | // fragment limit has to allow that, or a fragment that encoded cleanly is rejected on the |
| 103 | // way back in. This ratio is what the decoder's own bound is sized against. |
| 104 | expect(html.length).toBeGreaterThan(raw.length * 8); |
| 105 | expect(decodeComposerContextClipboardHtml(html)).toEqual(JSON.parse(raw)); |
| 106 | }); |
| 107 | |
| 108 | it("round-trips a fragment and drops records it cannot decode", () => { |
| 109 | const encoded = encodeComposerContextFragment({ |
| 110 | version: 1, |
| 111 | source: { environmentId: "env-1" as never, threadId: "thread-1" as never }, |
| 112 | records: [ |
| 113 | { version: 1, contextId: "ctx-1" as never, kind: "skill", label: "$x", name: "x" }, |
| 114 | { version: 1, contextId: "ctx-2" as never, kind: "image", label: "bad" } as never, |
| 115 | ], |
| 116 | }); |
| 117 | const decoded = decodeComposerContextFragment(encoded); |
| 118 | expect(decoded?.source.threadId).toBe("thread-1"); |
| 119 | expect(decoded?.records.map((record) => record.contextId)).toEqual(["ctx-1"]); |
| 120 | }); |
| 121 | |
| 122 | it("rejects garbage, other versions, and oversized payloads", () => { |
| 123 | expect(decodeComposerContextFragment(null)).toBeNull(); |
| 124 | expect(decodeComposerContextFragment("not json")).toBeNull(); |
| 125 | expect( |
| 126 | decodeComposerContextFragment( |
| 127 | JSON.stringify({ version: 2, source: { environmentId: "e" }, records: [] }), |
| 128 | ), |
| 129 | ).toBeNull(); |
| 130 | expect(decodeComposerContextFragment("x".repeat(2_000_001))).toBeNull(); |
| 131 | expect(COMPOSER_CONTEXT_CLIPBOARD_MIME).toMatch(/^web application\//); |
| 132 | }); |
| 133 | }); |