| 1 | import { describe, expect, it } from "vite-plus/test"; |
| 2 | |
| 3 | import { |
| 4 | buildNewThreadLink, |
| 5 | parseNewThreadLink, |
| 6 | parseThreadLinkPrompt, |
| 7 | promptLinkAppPath, |
| 8 | promptLinkDeepLink, |
| 9 | sanitizeLinkedPrompt, |
| 10 | withoutPromptLinkParams, |
| 11 | } from "./promptLinks.ts"; |
| 12 | |
| 13 | describe("sanitizeLinkedPrompt", () => { |
| 14 | it("removes characters a reader cannot see and counts them", () => { |
| 15 | const tagged = "rate me" + String.fromCodePoint(0xe0041, 0xe0042); |
| 16 | expect(sanitizeLinkedPrompt(`a\u200Bb\u202Ec\uFEFF${tagged}`)).toEqual({ |
| 17 | text: "abcrate me", |
| 18 | hiddenCharacters: 5, |
| 19 | }); |
| 20 | expect(sanitizeLinkedPrompt("a\u2028b\u2029c")).toEqual({ |
| 21 | text: "a\nb\nc", |
| 22 | hiddenCharacters: 0, |
| 23 | }); |
| 24 | }); |
| 25 | |
| 26 | it("keeps emoji sequences intact", () => { |
| 27 | const family = "\u{1F468}\u200D\u{1F469}\u200D\u{1F467}"; |
| 28 | const heart = "\u2764\uFE0F"; |
| 29 | expect(sanitizeLinkedPrompt(`${family} ${heart}`)).toEqual({ |
| 30 | text: `${family} ${heart}`, |
| 31 | hiddenCharacters: 0, |
| 32 | }); |
| 33 | expect(sanitizeLinkedPrompt("a\u200Db").hiddenCharacters).toBe(1); |
| 34 | expect(sanitizeLinkedPrompt("\u{1F468}\u200D").hiddenCharacters).toBe(1); |
| 35 | expect(sanitizeLinkedPrompt("\u{20000}\u200D\u{20001}").hiddenCharacters).toBe(1); |
| 36 | }); |
| 37 | }); |
| 38 | |
| 39 | describe("parseNewThreadLink", () => { |
| 40 | it("reads a shared prompt with its optional repository and model", () => { |
| 41 | const result = parseNewThreadLink( |
| 42 | "?prompt=Rate+me&repo=https://github.com/pingdotgg/t3code.git&provider=Codex&model=gpt-5", |
| 43 | ); |
| 44 | expect(result).toEqual({ |
| 45 | ok: true, |
| 46 | value: { |
| 47 | kind: "new", |
| 48 | prompt: { text: "Rate me", hiddenCharacters: 0 }, |
| 49 | repository: { owner: "pingdotgg", name: "t3code" }, |
| 50 | provider: "codex", |
| 51 | model: "gpt-5", |
| 52 | }, |
| 53 | }); |
| 54 | }); |
| 55 | |
| 56 | it("reads a pull request link, whose prompt is optional", () => { |
| 57 | for (const pr of [ |
| 58 | "pingdotgg/t3code/42", |
| 59 | "pingdotgg/t3code%2342", |
| 60 | "https://github.com/pingdotgg/t3code/pull/42/files", |
| 61 | ]) { |
| 62 | expect(parseNewThreadLink(`?pr=${pr}`)).toEqual({ |
| 63 | ok: true, |
| 64 | value: { |
| 65 | kind: "pull-request", |
| 66 | pullRequest: { owner: "pingdotgg", name: "t3code", number: 42 }, |
| 67 | prompt: null, |
| 68 | }, |
| 69 | }); |
| 70 | } |
| 71 | }); |
| 72 | |
| 73 | it("refuses links it cannot open safely", () => { |
| 74 | expect(parseNewThreadLink("?repo=a/b").ok).toBe(false); |
| 75 | expect(parseNewThreadLink("?prompt=%E2%80%8B").ok).toBe(false); |
| 76 | expect(parseThreadLinkPrompt("?prompt=%E2%80%8B").ok).toBe(false); |
| 77 | expect(parseNewThreadLink("?prompt=x&repo=../etc").ok).toBe(false); |
| 78 | expect(parseNewThreadLink(`?prompt=${"x".repeat(8_001)}`).ok).toBe(false); |
| 79 | }); |
| 80 | }); |
| 81 | |
| 82 | describe("thread links", () => { |
| 83 | it("reads the prompt and leaves other parameters in place", () => { |
| 84 | expect(parseThreadLinkPrompt("?prompt=Fix+it")).toEqual({ |
| 85 | ok: true, |
| 86 | value: { text: "Fix it", hiddenCharacters: 0 }, |
| 87 | }); |
| 88 | expect(parseThreadLinkPrompt("?panel=diff")).toEqual({ ok: true, value: null }); |
| 89 | expect(withoutPromptLinkParams("?prompt=x&panel=diff")).toBe("?panel=diff"); |
| 90 | }); |
| 91 | }); |
| 92 | |
| 93 | describe("link round trips", () => { |
| 94 | it("builds a link that parses back to the same prompt", () => { |
| 95 | const link = buildNewThreadLink({ |
| 96 | prompt: "Swear count & rating?", |
| 97 | repository: { owner: "pingdotgg", name: "t3code" }, |
| 98 | provider: "claudeAgent", |
| 99 | model: "opus", |
| 100 | }); |
| 101 | const path = promptLinkAppPath(link); |
| 102 | expect(path).toBe(new URL(link).pathname + new URL(link).search); |
| 103 | const parsed = parseNewThreadLink(new URL(link).search); |
| 104 | expect(parsed.ok && parsed.value.kind === "new" && parsed.value.prompt.text).toBe( |
| 105 | "Swear count & rating?", |
| 106 | ); |
| 107 | }); |
| 108 | |
| 109 | it("maps app scheme and hosted links to in-app paths, and nothing else", () => { |
| 110 | expect(promptLinkAppPath("t3code://new?prompt=hi")).toBe("/new?prompt=hi"); |
| 111 | expect(promptLinkAppPath("t3code-dev://threads/env-1/thread-1")).toBe("/env-1/thread-1"); |
| 112 | expect(promptLinkAppPath("https://app.t3.codes/env-1/thread-1?prompt=go")).toBe( |
| 113 | "/env-1/thread-1?prompt=go", |
| 114 | ); |
| 115 | expect(promptLinkAppPath("https://app.t3.codes/settings/general")).toBeNull(); |
| 116 | expect(promptLinkAppPath("https://evil.test/new?prompt=hi")).toBeNull(); |
| 117 | expect(promptLinkAppPath("t3code://app/index.html")).toBeNull(); |
| 118 | }); |
| 119 | |
| 120 | it("hands an in-app path to the installed app", () => { |
| 121 | expect(promptLinkDeepLink("/new?prompt=hi")).toBe("t3code://new?prompt=hi"); |
| 122 | expect(promptLinkDeepLink("/env-1/thread-1?prompt=hi")).toBe( |
| 123 | "t3code://threads/env-1/thread-1?prompt=hi", |
| 124 | ); |
| 125 | }); |
| 126 | }); |