@@ -6,16 +6,19 @@ const http = require('node:http');
66const path = require ( 'node:path' ) ;
77const { chromium, expect } = require ( '@playwright/test' ) ;
88const root = path . resolve ( __dirname , '../../src/frontend/web/von_interface' ) ;
9+ const markup = fs . readFileSync ( path . join ( root , 'templates/chat_tab.html' ) , 'utf8' ) . replace ( / { % [ \s \S ] * ?% } | { { [ \s \S ] * ?} } / g, '' ) ;
910const evidence = process . argv [ 2 ] || '.run/unread-navigation' ;
1011fs . mkdirSync ( evidence , { recursive : true } ) ;
1112let cursors = [ ] ;
13+ let background = false ;
14+ let pendingPage = null ;
1215const message = ( id , unread , day ) => ( { concept_id : id , created_at : `2026-09-${ day } T12:00:00Z` ,
1316 relationships : { '#V#has_sender' : [ '#V#bob' ] , '#V#has_recipient' : [ '#V#alice' ] } ,
14- concept_data : { content_fallback : id , read_by : unread ? [ ] : [ '#V#alice' ] } } ) ;
17+ concept_data : { content_fallback : ` ${ id } : ${ 'A realistic message for checking the reading position. ' . repeat ( 5 ) } ` , read_by : unread ? [ ] : [ '#V#alice' ] } } ) ;
1518const server = http . createServer ( async ( req , res ) => {
1619 if ( req . url === '/' ) {
1720 res . setHeader ( 'Content-Type' , 'text/html' ) ;
18- return res . end ( ' <!doctype html><meta name="viewport" content="width=device-width, initial-scale=1"><link rel="stylesheet" href="/static/styles.css"><div id="conversationWorkspace" class="show-message-exchange"><div id="messagesContainer"></div></div>' ) ;
21+ return res . end ( ` <!doctype html><meta name="viewport" content="width=device-width, initial-scale=1"><link rel="stylesheet" href="/static/styles.css"><link rel="stylesheet" href="/static/css/participantProfile.css"> ${ markup } ` ) ;
1922 }
2023 if ( req . url . startsWith ( '/static/' ) ) {
2124 const file = path . resolve ( root , `.${ req . url } ` ) ;
@@ -28,10 +31,14 @@ const server = http.createServer(async (req, res) => {
2831 if ( req . url === '/api/messages/exchange' ) {
2932 let body = '' ; for await ( const chunk of req ) body += chunk ;
3033 const { before } = JSON . parse ( body ) ; cursors . push ( before ) ;
31- const result = ! before ? { messages : [ message ( 'Latest read message' , false , '13' ) ] , before : 'middle' }
32- : before === 'middle' ? { messages : [ message ( 'Middle read message' , false , '12' ) ] , before : 'older' }
34+ const initial = Array . from ( { length : 20 } , ( _ , index ) => message ( `latest-${ String ( index ) . padStart ( 2 , '0' ) } ` , false , '13' ) ) ;
35+ const result = ! before ? { messages : background ? [ ...initial , message ( 'Background unread' , true , '14' ) ] : initial , before : 'middle' }
36+ : before === 'middle' ? { messages : Array . from ( { length : 10 } , ( _ , index ) => message ( `middle-${ index } ` , false , '12' ) ) , before : 'older' }
3337 : { messages : [ message ( 'Earlier unread' , true , '10' ) , message ( 'Most recent unread' , true , '11' ) ] , before : null } ;
34- return res . end ( JSON . stringify ( { ...result , current_user_id : '#V#alice' } ) ) ;
38+ const finish = ( ) => res . end ( JSON . stringify ( { ...result , current_user_id : '#V#alice' } ) ) ;
39+ if ( before || background ) pendingPage = finish ;
40+ else finish ( ) ;
41+ return ;
3542 }
3643 res . end ( JSON . stringify ( { success : true , messages : [ ] , profiles : [ ] } ) ) ;
3744} ) ;
@@ -42,25 +49,68 @@ const server = http.createServer(async (req, res) => {
4249 const results = [ ] ;
4350 for ( const width of [ 390 , 1280 ] ) {
4451 cursors = [ ] ;
52+ background = false ;
4553 const page = await browser . newPage ( { viewport : { width, height : 850 } } ) ;
4654 const errors = [ ] ; page . on ( 'pageerror' , error => errors . push ( error . message ) ) ;
4755 await page . goto ( `http://127.0.0.1:${ server . address ( ) . port } ` ) ;
4856 await page . evaluate ( async ( ) => {
4957 // Keep read markers stable while checking navigation independently.
5058 window . IntersectionObserver = undefined ;
59+ document . getElementById ( 'conversationWorkspace' ) . classList . add ( 'show-message-exchange' ) ;
60+ document . body . classList . add ( 'viewing-message-exchange' ) ;
5161 const { openMessageExchange } = await import ( '/static/js/components/messagePanel.js' ) ;
5262 await openMessageExchange ( { session_id : 'fixture' , viewer_id : '#V#alice' ,
5363 participant_ids : [ '#V#alice' , '#V#bob' ] , other_participant_ids : [ '#V#bob' ] ,
5464 session_name : 'Unread pagination fixture' , shared_unread_count : 2 } ) ;
5565 } ) ;
66+ const content = page . locator ( '#messageViewContent' ) ;
67+ assert . equal ( await content . evaluate ( el => el . scrollTop ) , 0 , 'default loading preserves the top' ) ;
68+ await content . evaluate ( el => { el . scrollTop = el . scrollHeight - el . clientHeight - 10 ; } ) ;
69+ const bottomPosition = await content . evaluate ( el => el . scrollTop ) ;
70+ assert . ok ( bottomPosition > 0 , 'fixture must overflow' ) ;
71+ background = true ;
72+ await page . evaluate ( ( ) => {
73+ window . refreshDone = import ( '/static/js/components/messagePanel.js' ) . then ( panel => panel . refreshOpenMessageExchange ( ) ) ;
74+ } ) ;
75+ await expect . poll ( ( ) => Boolean ( pendingPage ) ) . toBe ( true ) ;
76+ assert . equal ( await content . evaluate ( el => el . scrollTop ) , bottomPosition , 'pending background request must preserve position' ) ;
77+ pendingPage ( ) ; pendingPage = null ;
78+ await page . evaluate ( ( ) => window . refreshDone ) ;
79+ assert . equal ( await content . evaluate ( el => el . scrollTop ) , bottomPosition , 'background unread must not follow the bottom' ) ;
80+ // Reopen the initial fixture, then traverse delayed earlier pages.
81+ background = false ;
82+ await page . evaluate ( async ( ) => {
83+ const panel = await import ( '/static/js/components/messagePanel.js' ) ;
84+ await panel . openMessageExchange ( { session_id : 'fixture' , viewer_id : '#V#alice' ,
85+ participant_ids : [ '#V#alice' , '#V#bob' ] , other_participant_ids : [ '#V#bob' ] ,
86+ session_name : 'Unread pagination fixture' , shared_unread_count : 2 } ) ;
87+ } ) ;
88+ cursors = [ ] ;
89+ await content . evaluate ( el => { el . scrollTop = 450 ; } ) ;
90+ const anchor = await content . evaluate ( el => {
91+ const top = el . getBoundingClientRect ( ) . top ;
92+ const item = [ ...el . querySelectorAll ( '[data-contribution-id]' ) ] . find ( item => item . getBoundingClientRect ( ) . bottom > top ) ;
93+ return { id : item . dataset . contributionId , offset : item . getBoundingClientRect ( ) . top - top } ;
94+ } ) ;
95+ const offset = ( ) => page . locator ( `[data-contribution-id="${ anchor . id } "]` ) . evaluate ( el =>
96+ el . getBoundingClientRect ( ) . top - document . getElementById ( 'messageViewContent' ) . getBoundingClientRect ( ) . top ) ;
5697 await page . getByRole ( 'button' , { name : 'Jump to most recent unread' , exact : true } ) . click ( ) ;
98+ await expect . poll ( ( ) => Boolean ( pendingPage ) ) . toBe ( true ) ;
99+ assert . equal ( await offset ( ) , anchor . offset , 'pending go-to-unread preserves the visible message' ) ;
100+ pendingPage ( ) ; pendingPage = null ;
101+ await expect . poll ( ( ) => Boolean ( pendingPage ) ) . toBe ( true ) ;
102+ await page . evaluate ( ( ) => new Promise ( resolve => requestAnimationFrame ( ( ) => requestAnimationFrame ( resolve ) ) ) ) ;
103+ assert . ok ( Math . abs ( await offset ( ) - anchor . offset ) <= 1 , 'intermediate page preserves the visible message within one CSS pixel' ) ;
104+ await expect ( page . getByRole ( 'button' , { name : 'Loading unread messages…' , exact : true } ) ) . toBeVisible ( ) ;
105+ await page . screenshot ( { path : path . join ( evidence , `loading-${ width } .png` ) } ) ;
106+ pendingPage ( ) ; pendingPage = null ;
57107 const target = page . locator ( '[data-contribution-id="Most recent unread"]' ) ;
58108 await expect ( target ) . toBeFocused ( ) ;
59109 await expect ( target ) . toBeInViewport ( ) ;
60- assert . deepEqual ( cursors , [ null , 'middle' , 'older' ] ) ;
110+ assert . deepEqual ( cursors , [ 'middle' , 'older' ] ) ;
61111 assert . deepEqual ( errors , [ ] ) ;
62112 await page . screenshot ( { path : path . join ( evidence , `unread-${ width } .png` ) } ) ;
63- results . push ( { width, cursors : [ ...cursors ] , mostRecentUnreadFocused : true , targetInViewport : true } ) ;
113+ results . push ( { width, cursors : [ ...cursors ] , defaultTopPreserved : true , backgroundPosition : bottomPosition , intermediateAnchorOffset : anchor . offset , mostRecentUnreadFocused : true , targetInViewport : true } ) ;
64114 await page . close ( ) ;
65115 }
66116 fs . writeFileSync ( path . join ( evidence , 'result.json' ) , JSON . stringify ( { fixture : true , results } , null , 2 ) ) ;
0 commit comments