@@ -14,6 +14,7 @@ import {
1414import { createLogger } from '@sim/logger'
1515import { getErrorMessage } from '@sim/utils/errors'
1616import { sleep } from '@sim/utils/helpers'
17+ import { toRecord } from '@sim/utils/object'
1718
1819const logger = createLogger ( 'DocsNavigationE2E' , { logLevel : 'INFO' } )
1920const origin = process . env . DOCS_E2E_BASE_URL
@@ -162,6 +163,112 @@ async function verifyClipboard(
162163let browser : Browser | undefined
163164try {
164165 browser = await ( engine === 'webkit' ? webkit : chromium ) . launch ( )
166+ const feedbackBrowser = browser
167+ await check ( 'Feedback forwards votes and preserves unchanged retry identity' , async ( ) => {
168+ const cert = process . env . DOCS_FEEDBACK_TEST_CERT
169+ const key = process . env . DOCS_FEEDBACK_TEST_KEY
170+ const token = process . env . DOCS_FEEDBACK_WEBHOOK_TOKEN
171+ if ( ! cert || ! key || ! token ) throw new Error ( 'Feedback HTTPS fixture configuration is required' )
172+ const received : Array < { authorization : string | null ; body : Record < string , unknown > } > = [ ]
173+ let accept = false
174+ const intake = Bun . serve ( {
175+ hostname : '127.0.0.1' ,
176+ port : 3443 ,
177+ tls : { cert : await readFile ( cert ) , key : await readFile ( key ) } ,
178+ async fetch ( request ) {
179+ if ( received . length >= 8 ) return new Response ( null , { status : 429 } )
180+ received . push ( {
181+ authorization : request . headers . get ( 'authorization' ) ,
182+ body : toRecord ( await request . json ( ) ) ,
183+ } )
184+ return Response . json (
185+ { message : accept ? 'Webhook processed' : 'Unavailable' } ,
186+ { status : accept ? 200 : 503 }
187+ )
188+ } ,
189+ } )
190+ let context : BrowserContext | undefined
191+ try {
192+ context = await feedbackBrowser . newContext ( )
193+ const page = await context . newPage ( )
194+ const started = Date . now ( )
195+ await open ( page , '/introduction' )
196+ await page . getByRole ( 'button' , { name : 'No, this could be improved' , exact : true } ) . click ( )
197+ const comment = page . getByLabel ( 'What could be better? (optional)' , { exact : true } )
198+ await comment . fill ( ' Explain deployment prerequisites. ' )
199+ const send = page . getByRole ( 'button' , { name : 'Send feedback' , exact : true } )
200+ for ( let attempt = 0 ; attempt < 2 ; attempt ++ ) {
201+ const response = page . waitForResponse (
202+ ( response ) => new URL ( response . url ( ) ) . pathname === '/api/feedback'
203+ )
204+ await send . click ( )
205+ expect ( ( await response ) . status ( ) ) . toBe ( 502 )
206+ await expect ( page . getByRole ( 'alert' ) ) . toContainText ( 'Could not send feedback.' )
207+ await expect ( comment ) . toHaveValue ( ' Explain deployment prerequisites. ' )
208+ await expect ( send ) . toBeEnabled ( )
209+ }
210+ expect ( received ) . toHaveLength ( 2 )
211+ expect ( received [ 1 ] . body . submission_id ) . toBe ( received [ 0 ] . body . submission_id )
212+ expect ( received [ 0 ] . body . comment ) . toBe ( 'Explain deployment prerequisites.' )
213+ accept = true
214+ await comment . fill ( ' Explain deployment permissions. ' )
215+ const accepted = page . waitForResponse (
216+ ( response ) => new URL ( response . url ( ) ) . pathname === '/api/feedback'
217+ )
218+ await send . click ( )
219+ expect ( ( await accepted ) . status ( ) ) . toBe ( 202 )
220+ await expect (
221+ page . getByRole ( 'status' ) . filter ( { hasText : 'Thanks for your feedback.' } )
222+ ) . toBeVisible ( )
223+ expect ( received ) . toHaveLength ( 3 )
224+ expect ( received [ 2 ] . body . submission_id ) . not . toBe ( received [ 1 ] . body . submission_id )
225+ expect ( received [ 2 ] . body ) . toMatchObject ( {
226+ page : '/introduction' ,
227+ helpful : false ,
228+ comment : 'Explain deployment permissions.' ,
229+ } )
230+ await page
231+ . getByRole ( 'navigation' , { name : 'Page navigation' } )
232+ . getByRole ( 'link' , { name : 'Next Getting started' } )
233+ . click ( )
234+ const positive = page . waitForResponse (
235+ ( response ) => new URL ( response . url ( ) ) . pathname === '/api/feedback'
236+ )
237+ await page . getByRole ( 'button' , { name : 'Yes, this was helpful' , exact : true } ) . click ( )
238+ expect ( ( await positive ) . status ( ) ) . toBe ( 202 )
239+ await expect (
240+ page . getByRole ( 'status' ) . filter ( { hasText : 'Thanks for your feedback.' } )
241+ ) . toBeVisible ( )
242+ expect ( received ) . toHaveLength ( 4 )
243+ expect ( received [ 3 ] . body ) . toMatchObject ( {
244+ page : '/getting-started' ,
245+ helpful : true ,
246+ comment : '' ,
247+ } )
248+ expect ( received [ 3 ] . body . submission_id ) . not . toBe ( received [ 2 ] . body . submission_id )
249+ for ( const request of received ) {
250+ expect ( request . authorization ) . toBe ( `Bearer ${ token } ` )
251+ expect ( Object . keys ( request . body ) . sort ( ) ) . toEqual ( [
252+ 'comment' ,
253+ 'helpful' ,
254+ 'page' ,
255+ 'received_at' ,
256+ 'submission_id' ,
257+ ] )
258+ expect ( request . body . submission_id ) . toMatch ( / ^ [ 0 - 9 a - f - ] { 36 } $ / )
259+ expect ( typeof request . body . received_at ) . toBe ( 'string' )
260+ const timestamp = Date . parse ( String ( request . body . received_at ) )
261+ expect ( timestamp ) . toBeGreaterThanOrEqual ( started )
262+ expect ( timestamp ) . toBeLessThanOrEqual ( Date . now ( ) )
263+ }
264+ } finally {
265+ try {
266+ await context ?. close ( )
267+ } finally {
268+ await intake . stop ( true )
269+ }
270+ }
271+ } )
165272 const noScript = await browser . newContext ( {
166273 javaScriptEnabled : false ,
167274 viewport : { width : 1440 , height : 1000 } ,
0 commit comments