1import { describe, expect, it } from "vite-plus/test";
2
3import {
4 COMPOSER_CONTEXT_CLIPBOARD_MIME,
5 decodeComposerContextFragment,
6 encodeComposerContextFragment,
7 encodeComposerContextClipboardHtml,
8 decodeComposerContextClipboardHtml,
9} from "./composerContextClipboard.ts";
10
11describe("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});