@@ -4,6 +4,16 @@ import type { Page } from '@playwright/test'
44const webServerMode = process . env . PLAYWRIGHT_WEB_SERVER_MODE ?? 'dev'
55const appEntryPath = webServerMode === 'preview' ? '/index.html' : '/src/index.html'
66
7+ type ChatRequestMessage = {
8+ role ?: string
9+ content ?: string
10+ }
11+
12+ type ChatRequestBody = {
13+ metadata ?: unknown
14+ messages ?: ChatRequestMessage [ ]
15+ }
16+
717const waitForAppReady = async ( page : Page , path = appEntryPath ) => {
818 await page . goto ( path )
919 await expect ( page . getByRole ( 'heading' , { name : '@knighted/develop' } ) ) . toBeVisible ( )
@@ -100,6 +110,42 @@ const ensureDiagnosticsDrawerClosed = async (page: Page) => {
100110 await expect ( page . locator ( '#diagnostics-drawer' ) ) . toBeHidden ( )
101111}
102112
113+ const ensureAiChatDrawerOpen = async ( page : Page ) => {
114+ const toggle = page . locator ( '#ai-chat-toggle' )
115+ const isExpanded = await toggle . getAttribute ( 'aria-expanded' )
116+
117+ if ( isExpanded !== 'true' ) {
118+ await toggle . click ( )
119+ }
120+
121+ await expect ( page . locator ( '#ai-chat-drawer' ) ) . toBeVisible ( )
122+ }
123+
124+ const connectByotWithSingleRepo = async ( page : Page ) => {
125+ await page . route ( 'https://api.github.com/user/repos**' , async route => {
126+ await route . fulfill ( {
127+ status : 200 ,
128+ contentType : 'application/json' ,
129+ body : JSON . stringify ( [
130+ {
131+ id : 11 ,
132+ owner : { login : 'knightedcodemonkey' } ,
133+ name : 'develop' ,
134+ full_name : 'knightedcodemonkey/develop' ,
135+ default_branch : 'main' ,
136+ permissions : { push : true } ,
137+ } ,
138+ ] ) ,
139+ } )
140+ } )
141+
142+ await page . locator ( '#github-token-input' ) . fill ( 'github_pat_fake_chat_1234567890' )
143+ await page . locator ( '#github-token-add' ) . click ( )
144+ await expect ( page . locator ( '#github-repo-select' ) ) . toHaveValue (
145+ 'knightedcodemonkey/develop' ,
146+ )
147+ }
148+
103149const expectCollapseButtonState = async (
104150 page : Page ,
105151 panelName : 'component' | 'styles' | 'preview' ,
@@ -136,6 +182,8 @@ test('BYOT controls stay hidden when feature flag is disabled', async ({ page })
136182 const byotControls = page . locator ( '#github-ai-controls' )
137183 await expect ( byotControls ) . toHaveAttribute ( 'hidden' , '' )
138184 await expect ( byotControls ) . toBeHidden ( )
185+ await expect ( page . locator ( '#ai-chat-toggle' ) ) . toBeHidden ( )
186+ await expect ( page . locator ( '#ai-chat-drawer' ) ) . toBeHidden ( )
139187} )
140188
141189test ( 'BYOT controls render when feature flag is enabled by query param' , async ( {
@@ -147,6 +195,194 @@ test('BYOT controls render when feature flag is enabled by query param', async (
147195 await expect ( byotControls ) . toBeVisible ( )
148196 await expect ( page . locator ( '#github-token-input' ) ) . toBeVisible ( )
149197 await expect ( page . locator ( '#github-token-add' ) ) . toBeVisible ( )
198+ await expect ( page . locator ( '#github-ai-controls #ai-chat-toggle' ) ) . toBeHidden ( )
199+ } )
200+
201+ test ( 'AI chat drawer opens and closes when feature flag is enabled' , async ( { page } ) => {
202+ await waitForAppReady ( page , `${ appEntryPath } ?feature-ai=true` )
203+ await connectByotWithSingleRepo ( page )
204+
205+ const chatToggle = page . locator ( '#ai-chat-toggle' )
206+ const chatDrawer = page . locator ( '#ai-chat-drawer' )
207+
208+ await expect ( chatToggle ) . toBeVisible ( )
209+ await expect ( chatToggle ) . toHaveAttribute ( 'aria-expanded' , 'false' )
210+
211+ await chatToggle . click ( )
212+ await expect ( chatDrawer ) . toBeVisible ( )
213+ await expect ( chatToggle ) . toHaveAttribute ( 'aria-expanded' , 'true' )
214+
215+ await page . locator ( '#ai-chat-close' ) . click ( )
216+ await expect ( chatDrawer ) . toBeHidden ( )
217+ await expect ( chatToggle ) . toHaveAttribute ( 'aria-expanded' , 'false' )
218+ } )
219+
220+ test ( 'AI chat prefers streaming responses when available' , async ( { page } ) => {
221+ let streamRequestBody : ChatRequestBody | undefined
222+
223+ await page . route ( 'https://models.github.ai/inference/chat/completions' , async route => {
224+ streamRequestBody = route . request ( ) . postDataJSON ( ) as ChatRequestBody
225+
226+ await route . fulfill ( {
227+ status : 200 ,
228+ contentType : 'text/event-stream' ,
229+ body : [
230+ 'data: {"choices":[{"delta":{"content":"Streaming "}}]}' ,
231+ '' ,
232+ 'data: {"choices":[{"delta":{"content":"response ready"}}]}' ,
233+ '' ,
234+ 'data: [DONE]' ,
235+ '' ,
236+ ] . join ( '\n' ) ,
237+ } )
238+ } )
239+
240+ await waitForAppReady ( page , `${ appEntryPath } ?feature-ai=true` )
241+ await connectByotWithSingleRepo ( page )
242+ await ensureAiChatDrawerOpen ( page )
243+
244+ await page . locator ( '#ai-chat-prompt' ) . fill ( 'Summarize this repository.' )
245+ await page . locator ( '#ai-chat-send' ) . click ( )
246+
247+ await expect ( page . locator ( '#ai-chat-status' ) ) . toHaveText (
248+ 'Response streamed from GitHub.' ,
249+ )
250+ await expect ( page . locator ( '#ai-chat-rate' ) ) . toHaveText ( 'Rate limit info unavailable' )
251+ await expect ( page . locator ( '#ai-chat-messages' ) ) . toContainText (
252+ 'Summarize this repository.' ,
253+ )
254+ await expect ( page . locator ( '#ai-chat-messages' ) ) . toContainText (
255+ 'Streaming response ready' ,
256+ )
257+
258+ expect ( streamRequestBody ?. metadata ) . toBeUndefined ( )
259+ const systemMessage = streamRequestBody ?. messages ?. find (
260+ ( message : ChatRequestMessage ) => message . role === 'system' ,
261+ )
262+ const systemMessages = streamRequestBody ?. messages ?. filter (
263+ ( message : ChatRequestMessage ) => message . role === 'system' ,
264+ )
265+ expect ( systemMessage ?. content ) . toContain ( 'Selected repository context' )
266+ expect ( systemMessage ?. content ) . toContain ( 'Repository: knightedcodemonkey/develop' )
267+ expect ( systemMessage ?. content ) . toContain (
268+ 'Repository URL: https://github.com/knightedcodemonkey/develop' ,
269+ )
270+ expect (
271+ systemMessages ?. some ( ( message : ChatRequestMessage ) =>
272+ message . content ?. includes ( 'Editor context:' ) ,
273+ ) ,
274+ ) . toBe ( true )
275+ } )
276+
277+ test ( 'AI chat can disable editor context payload via checkbox' , async ( { page } ) => {
278+ let streamRequestBody : ChatRequestBody | undefined
279+
280+ await page . route ( 'https://models.github.ai/inference/chat/completions' , async route => {
281+ streamRequestBody = route . request ( ) . postDataJSON ( ) as ChatRequestBody
282+
283+ await route . fulfill ( {
284+ status : 200 ,
285+ contentType : 'text/event-stream' ,
286+ body : [
287+ 'data: {"choices":[{"delta":{"content":"ok"}}]}' ,
288+ '' ,
289+ 'data: [DONE]' ,
290+ '' ,
291+ ] . join ( '\n' ) ,
292+ } )
293+ } )
294+
295+ await waitForAppReady ( page , `${ appEntryPath } ?feature-ai=true` )
296+ await connectByotWithSingleRepo ( page )
297+ await ensureAiChatDrawerOpen ( page )
298+
299+ const includeEditorsToggle = page . locator ( '#ai-chat-include-editors' )
300+ await expect ( includeEditorsToggle ) . toBeChecked ( )
301+ await includeEditorsToggle . uncheck ( )
302+
303+ await page . locator ( '#ai-chat-prompt' ) . fill ( 'No editor source this time.' )
304+ await page . locator ( '#ai-chat-send' ) . click ( )
305+ await expect ( page . locator ( '#ai-chat-status' ) ) . toHaveText (
306+ 'Response streamed from GitHub.' ,
307+ )
308+ await expect ( page . locator ( '#ai-chat-rate' ) ) . toHaveText ( 'Rate limit info unavailable' )
309+
310+ expect ( streamRequestBody ?. metadata ) . toBeUndefined ( )
311+ const systemMessages = streamRequestBody ?. messages ?. filter (
312+ ( message : ChatRequestMessage ) => message . role === 'system' ,
313+ )
314+ expect (
315+ systemMessages ?. some ( ( message : ChatRequestMessage ) =>
316+ message . content ?. includes ( 'Selected repository context' ) ,
317+ ) ,
318+ ) . toBe ( true )
319+ expect (
320+ systemMessages ?. some ( ( message : ChatRequestMessage ) =>
321+ message . content ?. includes (
322+ 'Repository URL: https://github.com/knightedcodemonkey/develop' ,
323+ ) ,
324+ ) ,
325+ ) . toBe ( true )
326+ expect (
327+ systemMessages ?. some ( ( message : ChatRequestMessage ) =>
328+ message . content ?. includes ( 'Editor context:' ) ,
329+ ) ,
330+ ) . toBe ( false )
331+ } )
332+
333+ test ( 'AI chat falls back to non-streaming response when streaming fails' , async ( {
334+ page,
335+ } ) => {
336+ let streamAttemptCount = 0
337+ let fallbackAttemptCount = 0
338+
339+ await page . route ( 'https://models.github.ai/inference/chat/completions' , async route => {
340+ const body = route . request ( ) . postDataJSON ( ) as { stream ?: boolean } | null
341+ if ( body ?. stream ) {
342+ streamAttemptCount += 1
343+ await route . fulfill ( {
344+ status : 502 ,
345+ contentType : 'application/json' ,
346+ body : JSON . stringify ( { message : 'stream failed' } ) ,
347+ } )
348+ return
349+ }
350+
351+ fallbackAttemptCount += 1
352+ await route . fulfill ( {
353+ status : 200 ,
354+ contentType : 'application/json' ,
355+ body : JSON . stringify ( {
356+ rate_limit : {
357+ remaining : 17 ,
358+ reset : 1704067200 ,
359+ } ,
360+ choices : [
361+ {
362+ message : {
363+ role : 'assistant' ,
364+ content : 'Fallback response from JSON path.' ,
365+ } ,
366+ } ,
367+ ] ,
368+ } ) ,
369+ } )
370+ } )
371+
372+ await waitForAppReady ( page , `${ appEntryPath } ?feature-ai=true` )
373+ await connectByotWithSingleRepo ( page )
374+ await ensureAiChatDrawerOpen ( page )
375+
376+ await page . locator ( '#ai-chat-prompt' ) . fill ( 'Use fallback path.' )
377+ await page . locator ( '#ai-chat-send' ) . click ( )
378+
379+ await expect ( page . locator ( '#ai-chat-status' ) ) . toHaveText ( 'Fallback response loaded.' )
380+ await expect ( page . locator ( '#ai-chat-rate' ) ) . toHaveText ( 'Remaining 17, resets 00:00 UTC' )
381+ await expect ( page . locator ( '#ai-chat-messages' ) ) . toContainText (
382+ 'Fallback response from JSON path.' ,
383+ )
384+ expect ( streamAttemptCount ) . toBeGreaterThan ( 0 )
385+ expect ( fallbackAttemptCount ) . toBeGreaterThan ( 0 )
150386} )
151387
152388test ( 'BYOT remembers selected repository across reloads' , async ( { page } ) => {
0 commit comments