2026-08-28 16:14:44 +02:00
import type { Page } from "@playwright/test" ;
import { settledShot } from "../e2e/utils/element-tracker" ;
import { test , expect } from "./fixtures" ;
/ * *
* Findings group Y ( Y - 1 / Y - 3 , "selection kept on deleted element" ) — regression
* gate . Written red on 2026 - 08 - 28 , green with the fix the same day .
*
* Was : the presence overlay ( collab_presence_core . h ) was cleared + redrawn
* ONLY on kicadCollabSetRemote / SetPins / SetStyle / zoom . A document change
* — the remote apply of a peer ' s delete , or a local delete — never scheduled
* a redraw , so a peer ' s selection box / cross - app ghost kept being painted at
* the dead item ' s last bbox until some unrelated awareness change arrived .
* Now : every collab - listener trigger ( local commit AND remote apply ) queues
* CORE : : onDocChanged ( ) on the apply coroutine — shapes repaint from the live
* document and the local selection is re - checked post - settle .
*
* Second half : the LOCAL selection emit used to be triggered only by canvas
* LEFT_UP / RIGHT_UP / KEY_UP / wheel events ( plus a listener piggyback that
* could run before the commit finished ) . A delete reaching the commit without
* such an event ( menu , toolbar , context menu , programmatic / remote release )
* left the dead uuids published for every peer . onDocChanged ( ) re - checks
* after the commit body completes .
* /
const WIRE1 = "22222222-0000-0000-0000-000000000001" ;
const WIRE2 = "22222222-0000-0000-0000-000000000002" ;
const SAMPLE_SCH = ` (kicad_sch
\ t ( version 20260306 )
\ t ( generator "eeschema" )
\ t ( generator_version "9.0" )
\ t ( uuid "11111111-1111-1111-1111-111111111111" )
\ t ( paper "A4" )
\ t ( lib_symbols )
\ t ( wire ( pts ( xy 50.8 50.8 ) ( xy 101.6 50.8 ) ) ( stroke ( width 0 ) ( type default ) ) ( uuid "${WIRE1}" ) )
\ t ( wire ( pts ( xy 50.8 76.2 ) ( xy 101.6 76.2 ) ) ( stroke ( width 0 ) ( type default ) ) ( uuid "${WIRE2}" ) )
\ t ( sheet_instances ( path "/" ( page "1" ) ) )
)
` ;
type FS = { mkdirTree ( p : string ) : void ; writeFile ( p : string , d : string ) : void } ;
type Mod = {
kicadOpenFile ( p : string ) : unknown ;
kicadCollabPresenceStart ( ) : void ;
kicadCollabSetRemote ( j : string ) : void ;
kicadCollabGetSelection ( ) : string ;
kicadCollabTestSelectByUuid ( id : string ) : boolean ;
kicadCollabTestSelectFirst ( ) : string ;
kicadCollabTestRemoveItem ( id : string ) : boolean ;
kicadCollabTestListItems ( ) : string ;
} ;
type W = {
FS : FS ;
Module : Mod ;
kicadCollab? : Record < string , unknown > ;
__selEmits? : string [ ] [ ] ;
} ;
async function galPanel ( page : Page ) {
const glId = await page . evaluate ( ( ) = > {
const visible = Array . from ( document . querySelectorAll ( '[id^="glcanvas-"]' ) )
. map ( ( c ) = > c as HTMLCanvasElement )
. find (
( c ) = >
window . getComputedStyle ( c ) . display !== "none" &&
c . getBoundingClientRect ( ) . width > 0 ,
) ;
return visible ? . id ? ? null ;
} ) ;
expect ( glId , "no visible GAL panel found" ) . toBeTruthy ( ) ;
return page . locator ( ` # ${ glId } ` ) ;
}
async function bootAndOpen ( page : Page ) : Promise < void > {
await page . goto ( "/kicad/eeschema.html" ) ;
await expect ( page . locator ( "#canvas" ) ) . toBeVisible ( { timeout : 90000 } ) ;
await page . waitForFunction ( ( ) = > ! ! window . wxElementRegistry , null , { timeout : 90000 } ) ;
await page . waitForFunction (
( ) = > {
const m = ( window as unknown as { Module? : Partial < Mod > } ) . Module ;
return (
typeof m ? . kicadOpenFile === "function" &&
typeof m ? . kicadCollabSetRemote === "function" &&
typeof m ? . kicadCollabTestRemoveItem === "function"
) ;
} ,
null ,
{ timeout : 90000 } ,
) ;
await page . waitForFunction (
( ) = >
! ! window . wxElementRegistry &&
window . wxElementRegistry
. findAll ( { visible : true } )
. some ( ( e ) = > /Frame$/ . test ( e . typeName ) || ( e . name || "" ) . endsWith ( "Frame" ) ) ,
null ,
{ timeout : 90000 } ,
) ;
await page . evaluate (
( { content } ) = > {
const w = window as unknown as W ;
const dir = "/home/kicad/documents" ;
try {
w . FS . mkdirTree ( dir ) ;
} catch {
/* exists */
}
const p = ` ${ dir } /stale.kicad_sch ` ;
w . FS . writeFile ( p , content ) ;
w . Module . kicadOpenFile ( p ) ;
} ,
{ content : SAMPLE_SCH } ,
) ;
await expect
. poll ( ( ) = > page . title ( ) , { timeout : 60000 , intervals : [ 500 ] } )
. toMatch ( /stale/i ) ;
await page . evaluate ( ( ) = > {
const w = window as unknown as W ;
w . __selEmits = [ ] ;
w . kicadCollab = {
. . . w . kicadCollab ,
onSelection : ( json : string ) = > w . __selEmits ! . push ( JSON . parse ( json ) ) ,
} ;
w . Module . kicadCollabPresenceStart ( ) ;
} ) ;
}
const setRemote = ( page : Page , snapshot : unknown ) = >
page . evaluate (
( s ) = > ( window as unknown as W ) . Module . kicadCollabSetRemote ( JSON . stringify ( s ) ) ,
snapshot ,
) ;
const removeItem = ( page : Page , id : string ) = >
page . evaluate ( ( u ) = > ( window as unknown as W ) . Module . kicadCollabTestRemoveItem ( u ) , id ) ;
const lastEmit = ( page : Page ) = >
page . evaluate ( ( ) = > ( window as unknown as W ) . __selEmits ! . at ( - 1 ) ? ? null ) ;
for ( const kind of [ "selection" , "xsel" ] as const ) {
test ( ` remote ${ kind } outline is dropped when the item is deleted (redraw on doc change) ` , async ( {
page ,
} ) = > {
await bootAndOpen ( page ) ;
const canvas = await galPanel ( page ) ;
const baseline = await settledShot ( canvas ) ;
// bob (same room, or a pcbnew tab via cross-app xsel) has WIRE1 selected.
const peer = {
id : "bob" ,
name : "bob" ,
color : "#ef4444" ,
cursor : null ,
selection : kind === "selection" ? [ WIRE1 ] : [ ] ,
. . . ( kind === "xsel" ? { xsel : [ WIRE1 ] } : { } ) ,
} ;
await setRemote ( page , { peers : [ peer ] } ) ;
await expect
. poll ( async ( ) = > ! ( await canvas . screenshot ( ) ) . equals ( baseline ) , {
timeout : 15000 ,
intervals : [ 500 ] ,
message : "peer outline never painted" ,
} )
. toBe ( true ) ;
// WIRE1 is deleted through a real SCH_COMMIT — the same path a remote
// apply (peer deleted it) or a local menu delete takes.
expect ( await removeItem ( page , WIRE1 ) ) . toBe ( true ) ;
2026-08-28 21:30:49 +02:00
// Documented interaction dwell: the overlay repaint has no observable signal
// — settledShot below compares stable frames, this only lets it queue.
2026-09-01 09:36:26 +02:00
await page . waitForTimeout ( 1500 ) ; // eslint-disable-line -- documented interaction dwell: the overlay repaint after delete has no observable; settledShot below only compares stable frames
2026-08-28 16:14:44 +02:00
const afterDelete = await settledShot ( canvas ) ;
// Ground truth for "what the overlay should look like now": force the
// overlay to rebuild from the SAME peer snapshot — WIRE1 no longer
// resolves, so nothing is drawn for it.
await setRemote ( page , { peers : [ peer ] } ) ;
2026-09-01 09:36:26 +02:00
await page . waitForTimeout ( 1500 ) ; // eslint-disable-line -- documented interaction dwell: the overlay rebuild from the pushed peer snapshot has no observable
2026-08-28 16:14:44 +02:00
const afterRepush = await settledShot ( canvas ) ;
expect (
afterDelete . equals ( afterRepush ) ,
` ${ kind } : overlay still shows bob's outline on the deleted wire (no redraw on doc change) ` ,
) . toBe ( true ) ;
} ) ;
}
test ( "local emit: a commit-path delete of the selected item publishes the empty selection" , async ( {
page ,
} ) = > {
await bootAndOpen ( page ) ;
const id = await page . evaluate ( ( ) = >
( window as unknown as W ) . Module . kicadCollabTestSelectFirst ( ) ,
) ;
expect ( id ) . toBeTruthy ( ) ;
await expect . poll ( ( ) = > lastEmit ( page ) , { timeout : 10000 } ) . toEqual ( [ id ] ) ;
// Delete WITHOUT a canvas key/mouse event (menu / toolbar / context-menu /
// remote-release shaped): real SCH_COMMIT, item leaves the selection.
expect ( await removeItem ( page , id ) ) . toBe ( true ) ;
await expect
. poll (
( ) = >
page . evaluate ( ( ) = >
JSON . parse ( ( window as unknown as W ) . Module . kicadCollabGetSelection ( ) ) ,
) ,
{ timeout : 10000 , message : "the commit should have dropped the item from the live selection" } ,
)
. toEqual ( [ ] ) ;
// Peers learn the selection is gone (was: the last emit stayed [id] —
// no LEFT_UP/RIGHT_UP/KEY_UP/wheel closed the delete).
await expect
. poll ( ( ) = > lastEmit ( page ) , { timeout : 5000 , intervals : [ 250 ] } )
. toEqual ( [ ] ) ;
} ) ;
test ( "local emit: keyboard Delete on the canvas does publish the empty selection (control)" , async ( {
page ,
} ) = > {
await bootAndOpen ( page ) ;
const canvas = await galPanel ( page ) ;
const box = await canvas . boundingBox ( ) ;
expect ( box ) . toBeTruthy ( ) ;
// Give the wx canvas keyboard focus with a click on empty sheet space (this
// clears any selection — the programmatic select comes after).
await page . mouse . click ( box ! . x + box ! . width * 0.5 , box ! . y + box ! . height * 0.95 ) ;
2026-08-28 21:30:49 +02:00
await page . locator ( "#canvas" ) . focus ( ) . catch ( ( ) = > { } ) ; // documented best-effort: wx canvas may reject DOM focus after the click already focused it
2026-08-28 16:14:44 +02:00
const id = await page . evaluate ( ( ) = >
( window as unknown as W ) . Module . kicadCollabTestSelectFirst ( ) ,
) ;
expect ( id ) . toBeTruthy ( ) ;
await expect . poll ( ( ) = > lastEmit ( page ) , { timeout : 10000 } ) . toEqual ( [ id ] ) ;
await page . keyboard . press ( "Delete" ) ;
await expect
. poll (
( ) = >
page . evaluate ( ( ) = >
JSON . parse ( ( window as unknown as W ) . Module . kicadCollabGetSelection ( ) ) ,
) ,
{ timeout : 10000 , message : "Delete key never deleted the selected item (focus?)" } ,
)
. toEqual ( [ ] ) ;
await expect . poll ( ( ) = > lastEmit ( page ) , { timeout : 5000 , intervals : [ 250 ] } ) . toEqual ( [ ] ) ;
} ) ;