@@ -7,6 +7,11 @@ import {
77 resetTerminalImageSupportCache ,
88} from '../terminal-images'
99
10+ /** Helper: await renderInlineImage and return the result. */
11+ const render = (
12+ ...args : Parameters < typeof renderInlineImage >
13+ ) : Promise < string | null > => renderInlineImage ( ...args )
14+
1015/** Detect kitty support and keep it cached so renderInlineImage uses it. */
1116const useKitty = ( ) => {
1217 resetTerminalImageSupportCache ( )
@@ -82,36 +87,30 @@ describe('detectTerminalImageSupport', () => {
8287} )
8388
8489describe ( 'getKittyFormat' , ( ) => {
85- test ( 'maps PNG to 100 ' , ( ) => {
90+ test ( 'always returns 100 ( PNG) — the only spec-compatible format id ' , ( ) => {
8691 expect ( getKittyFormat ( 'image/png' ) ) . toBe ( 100 )
87- } )
88-
89- test ( 'maps JPEG to 102' , ( ) => {
90- expect ( getKittyFormat ( 'image/jpeg' ) ) . toBe ( 102 )
91- } )
92-
93- test ( 'maps WebP to 103' , ( ) => {
94- expect ( getKittyFormat ( 'image/webp' ) ) . toBe ( 103 )
95- } )
96-
97- test ( 'maps GIF to 104' , ( ) => {
98- expect ( getKittyFormat ( 'image/gif' ) ) . toBe ( 104 )
99- } )
100-
101- test ( 'defaults unknown to PNG' , ( ) => {
92+ expect ( getKittyFormat ( 'image/jpeg' ) ) . toBe ( 100 )
93+ expect ( getKittyFormat ( 'image/webp' ) ) . toBe ( 100 )
94+ expect ( getKittyFormat ( 'image/gif' ) ) . toBe ( 100 )
10295 expect ( getKittyFormat ( undefined ) ) . toBe ( 100 )
10396 expect ( getKittyFormat ( 'application/pdf' ) ) . toBe ( 100 )
10497 } )
10598} )
10699
100+ /**
101+ * Kitty spec only defines f=24 (RGB), f=32 (RGBA), f=100 (PNG).
102+ * Any other format id will be silently dropped or errored by real terminals.
103+ */
104+ const VALID_KITTY_FORMAT_IDS = new Set ( [ 24 , 32 , 100 ] )
105+
107106describe ( 'generateKittyImageSequence (via renderInlineImage)' , ( ) => {
108107 beforeEach ( ( ) => {
109108 resetTerminalImageSupportCache ( )
110109 } )
111110
112- test ( 'single chunk carries m=0 to close the transmission' , ( ) => {
111+ test ( 'single chunk carries m=0 to close the transmission' , async ( ) => {
113112 useKitty ( )
114- const seq = renderInlineImage ( 'aGVsbG8=' , {
113+ const seq = await render ( 'aGVsbG8=' , {
115114 width : 4 ,
116115 height : 3 ,
117116 mediaType : 'image/png' ,
@@ -125,34 +124,34 @@ describe('generateKittyImageSequence (via renderInlineImage)', () => {
125124 expect ( seq ) . toEndWith ( 'aGVsbG8=\x1b\\' )
126125 } )
127126
128- test ( 'multi-chunk: full control only on first chunk; m=1 middle; m=0 last' , ( ) => {
127+ test ( 'multi-chunk: full control only on first chunk; m=1 middle; m=0 last' , async ( ) => {
129128 useKitty ( )
130129 // 9000 base64 chars → 3 chunks (4096 + 4096 + 808)
131- const seq = renderInlineImage ( 'A' . repeat ( 9000 ) , {
130+ const seq = await render ( 'A' . repeat ( 9000 ) , {
132131 width : 10 ,
133132 height : 5 ,
134- mediaType : 'image/jpeg ' ,
133+ mediaType : 'image/png ' ,
135134 } )
136- expect ( seq ) . toContain ( 'f=102 ' )
135+ expect ( seq ) . toContain ( 'f=100 ' )
137136 expect ( seq ) . toContain ( 'c=10' )
138137
139138 const parts = seq ! . split ( '\x1b\\' ) . filter ( Boolean )
140139 expect ( parts ) . toHaveLength ( 3 )
141140
142141 // First chunk: full control data + m=1
143142 expect ( parts [ 0 ] ) . toContain ( 'a=T' )
144- expect ( parts [ 0 ] ) . toContain ( 'f=102 ' )
143+ expect ( parts [ 0 ] ) . toContain ( 'f=100 ' )
145144 expect ( parts [ 0 ] ) . toContain ( 'm=1' )
146145 // Middle chunk: m only — no a=, no f=, no c=, no r=
147146 expect ( parts [ 1 ] ) . toMatch ( / ^ \x1b _ G m = 1 ; A { 4096 } $ / )
148147 // Last chunk: m=0
149148 expect ( parts [ 2 ] ) . toMatch ( / ^ \x1b _ G m = 0 ; A { 808 } $ / )
150149 } )
151150
152- test ( 'subsequent chunks never repeat a=T / f= / c= (kitty spec)' , ( ) => {
151+ test ( 'subsequent chunks never repeat a=T / f= / c= (kitty spec)' , async ( ) => {
153152 useKitty ( )
154153 // 9000 chars → 3 chunks, so index 1 is a true middle chunk.
155- const seq = renderInlineImage ( 'B' . repeat ( 9000 ) , {
154+ const seq = await render ( 'B' . repeat ( 9000 ) , {
156155 mediaType : 'image/png' ,
157156 } )
158157 const middle = seq ! . split ( '\x1b\\' ) [ 1 ]
@@ -161,31 +160,74 @@ describe('generateKittyImageSequence (via renderInlineImage)', () => {
161160 expect ( middle ) . not . toContain ( 'c=' )
162161 expect ( middle ) . toMatch ( / ^ \x1b _ G m = 1 ; / )
163162 } )
163+
164+ test ( 'non-PNG payload is converted to PNG before transmission' , async ( ) => {
165+ useKitty ( )
166+ // 'hello' as base64 — pretending it's JPEG. The conversion will re-encode
167+ // it as a tiny PNG, so the output will differ from the raw input, but
168+ // f=100 must be used.
169+ const seq = await render ( 'aGVsbG8=' , {
170+ mediaType : 'image/jpeg' ,
171+ } )
172+ expect ( seq ) . toContain ( 'f=100' )
173+ // The payload must not be the original 'aGVsbG8=' — it was re-encoded as PNG.
174+ expect ( seq ) . not . toContain ( 'aGVsbG8=' )
175+ } )
176+
177+ test . each ( [ 'image/png' , 'image/jpeg' , 'image/webp' , 'image/gif' , undefined ] ) (
178+ 'escape sequence for %s uses a spec-compliant format id (f=100|24|32)' ,
179+ async ( mediaType ) => {
180+ useKitty ( )
181+ const seq = await render ( 'aGVsbG8=' , { mediaType } )
182+ // Extract the f= value from the first chunk's control data.
183+ const fMatch = seq ! . match ( / f = ( \d + ) / )
184+ expect ( fMatch ) . not . toBeNull ( )
185+ const formatId = Number ( fMatch ! [ 1 ] )
186+ expect ( VALID_KITTY_FORMAT_IDS ) . toContain ( formatId )
187+ } ,
188+ )
189+
190+ test ( 'no fabricated format ids (101-104) appear in any chunk' , async ( ) => {
191+ useKitty ( )
192+ // Use a large payload to force multiple chunks and cover all code paths.
193+ const seq = await render ( 'C' . repeat ( 9000 ) , { mediaType : 'image/jpeg' } )
194+ const allFormatIds = [ ...seq ! . matchAll ( / f = ( \d + ) / g) ] . map ( ( m ) =>
195+ Number ( m [ 1 ] ) ,
196+ )
197+ for ( const id of allFormatIds ) {
198+ expect ( VALID_KITTY_FORMAT_IDS ) . toContain ( id )
199+ }
200+ // Specifically ensure none of the fabricated ids from the old code appear.
201+ expect ( allFormatIds ) . not . toContain ( 101 )
202+ expect ( allFormatIds ) . not . toContain ( 102 )
203+ expect ( allFormatIds ) . not . toContain ( 103 )
204+ expect ( allFormatIds ) . not . toContain ( 104 )
205+ } )
164206} )
165207
166208describe ( 'generateITerm2ImageSequence (via renderInlineImage)' , ( ) => {
167209 beforeEach ( ( ) => {
168210 resetTerminalImageSupportCache ( )
169211 } )
170212
171- test ( 'size param is the decoded byte length, not the base64 length' , ( ) => {
213+ test ( 'size param is the decoded byte length, not the base64 length' , async ( ) => {
172214 useITerm2 ( )
173- const seq = renderInlineImage ( 'aGVsbG8=' , { filename : 'x.png' } )
215+ const seq = await render ( 'aGVsbG8=' , { filename : 'x.png' } )
174216 // 'hello' → 5 decoded bytes; base64 'aGVsbG8=' → 8 chars
175217 expect ( seq ) . toContain ( 'size=5' )
176218 expect ( seq ) . not . toContain ( 'size=8' )
177219 expect ( seq ) . toContain ( 'inline=1' )
178220 expect ( seq ) . toContain ( 'name=eC5wbmc=' )
179221 } )
180222
181- test ( 'returns null when the terminal does not support inline images' , ( ) => {
223+ test ( 'returns null when the terminal does not support inline images' , async ( ) => {
182224 // Prime the cache with an explicit 'none' so the assertion doesn't depend
183225 // on whatever terminal this test happens to run inside.
184226 resetTerminalImageSupportCache ( )
185227 expect (
186228 detectTerminalImageSupport ( { TERM : 'xterm-256color' } as any ) ,
187229 ) . toBe ( 'none' )
188- const seq = renderInlineImage ( 'aGVsbG8=' , { } )
230+ const seq = await render ( 'aGVsbG8=' , { } )
189231 expect ( seq ) . toBeNull ( )
190232 } )
191233} )
0 commit comments