44 * to component page objects (e.g., po.chatActions.sendPrompt()).
55 */
66
7- import { Page , expect } from "@playwright/test" ;
7+ import { Page , expect , type Locator , type TestInfo } from "@playwright/test" ;
88import { ElectronApplication } from "playwright" ;
99import fs from "fs" ;
10+ import path from "path" ;
1011
1112import { generateAppFilesSnapshotData } from "../generateAppFilesSnapshotData" ;
1213import {
@@ -15,6 +16,7 @@ import {
1516 normalizeVersionedFiles ,
1617 normalizePath ,
1718 prettifyDump ,
19+ normalizeMessagesAriaSnapshot ,
1820} from "../utils" ;
1921
2022// Import component page objects
@@ -55,14 +57,20 @@ export class PageObject {
5557 public appManagement : AppManagement ;
5658 public promptLibrary : PromptLibrary ;
5759 public browserNotifications : BrowserNotifications ;
60+ private stableMessageSnapshotIndex = 0 ;
5861
5962 constructor (
6063 public electronApp : ElectronApplication ,
6164 public page : Page ,
62- { userDataDir, fakeLlmPort } : { userDataDir : string ; fakeLlmPort : number } ,
65+ {
66+ userDataDir,
67+ fakeLlmPort,
68+ testInfo,
69+ } : { userDataDir : string ; fakeLlmPort : number ; testInfo ?: TestInfo } ,
6370 ) {
6471 this . userDataDir = userDataDir ;
6572 this . fakeLlmPort = fakeLlmPort ;
73+ this . testInfo = testInfo ;
6674
6775 // Initialize component page objects
6876 this . githubConnector = new GitHubConnector ( this . page , fakeLlmPort ) ;
@@ -80,6 +88,90 @@ export class PageObject {
8088 this . browserNotifications = new BrowserNotifications ( this . page ) ;
8189 }
8290
91+ private testInfo ?: TestInfo ;
92+
93+ private nextStableMessageSnapshotPath ( name ?: string ) {
94+ if ( name ) {
95+ const snapshotName = name . endsWith ( ".aria.yml" )
96+ ? name
97+ : `${ name } .aria.yml` ;
98+ return this . testInfo ?. snapshotPath ( snapshotName , {
99+ kind : "aria" ,
100+ } ) ;
101+ }
102+
103+ this . stableMessageSnapshotIndex ++ ;
104+ if ( ! this . testInfo ) {
105+ return undefined ;
106+ }
107+ const title = this . testInfo ?. title ?? "messages" ;
108+ // Mirrors Playwright's snapshot-name sanitization: everything except
109+ // letters, digits, and "-" becomes a "-" so auto-derived names line up
110+ // with the files toMatchAriaSnapshot() would generate.
111+ const normalizedTitle =
112+ title
113+ . replace ( / [ \x00 - \x2C \x2E - \x2F \x3A - \x40 \x5B - \x60 \x7B - \x7F ] + / g, "-" )
114+ . replace ( / ^ - + / , "" )
115+ . replace ( / - + $ / , "" ) || "messages" ;
116+ return this . testInfo . snapshotPath (
117+ `${ normalizedTitle } -${ this . stableMessageSnapshotIndex } .aria.yml` ,
118+ { kind : "aria" } ,
119+ ) ;
120+ }
121+
122+ private async expectStableMessageAriaSnapshot (
123+ actualSnapshot : string ,
124+ name ?: string ,
125+ ) {
126+ const snapshotPath = this . nextStableMessageSnapshotPath ( name ) ;
127+ if ( ! snapshotPath ) {
128+ expect ( actualSnapshot ) . toMatchSnapshot ( ) ;
129+ return ;
130+ }
131+
132+ const updateSnapshots = this . testInfo ?. config . updateSnapshots ?? "none" ;
133+ const snapshotExists = fs . existsSync ( snapshotPath ) ;
134+ const shouldUpdate =
135+ updateSnapshots === "all" ||
136+ updateSnapshots === "changed" ||
137+ ( updateSnapshots === "missing" && ! snapshotExists ) ;
138+
139+ if ( shouldUpdate ) {
140+ fs . writeFileSync ( snapshotPath , actualSnapshot ) ;
141+ if ( updateSnapshots === "missing" ) {
142+ // Match Playwright's snapshot semantics: a missing baseline is
143+ // written but still fails the test, so a renamed/typo'd snapshot
144+ // name cannot silently pass on CI.
145+ throw new Error (
146+ `ARIA snapshot is missing at ${ snapshotPath } , writing actual. Re-run the test to use the new baseline.` ,
147+ ) ;
148+ }
149+ return ;
150+ }
151+
152+ if ( ! snapshotExists ) {
153+ throw new Error ( `ARIA snapshot does not exist: ${ snapshotPath } ` ) ;
154+ }
155+
156+ const expectedSnapshot = fs . readFileSync ( snapshotPath , "utf8" ) ;
157+ if ( actualSnapshot !== expectedSnapshot && this . testInfo ) {
158+ const baseName = path . basename ( snapshotPath , ".aria.yml" ) ;
159+ const actualPath = this . testInfo . outputPath (
160+ `${ baseName } -actual.aria.yml` ,
161+ ) ;
162+ fs . writeFileSync ( actualPath , actualSnapshot ) ;
163+ await this . testInfo . attach ( `${ baseName } -expected` , {
164+ path : snapshotPath ,
165+ contentType : "text/plain" ,
166+ } ) ;
167+ await this . testInfo . attach ( `${ baseName } -actual` , {
168+ path : actualPath ,
169+ contentType : "text/plain" ,
170+ } ) ;
171+ }
172+ expect ( actualSnapshot ) . toBe ( expectedSnapshot ) ;
173+ }
174+
83175 // ================================
84176 // Setup Methods
85177 // ================================
@@ -338,33 +430,44 @@ export class PageObject {
338430
339431 async snapshotMessages ( {
340432 replaceDumpPath = false ,
433+ name,
434+ stable = true ,
341435 timeout,
342- } : { replaceDumpPath ?: boolean ; timeout ?: number } = { } ) {
343- // NOTE: once you have called this, you can NOT manipulate the UI anymore or React will break.
436+ } : {
437+ replaceDumpPath ?: boolean ;
438+ name ?: string ;
439+ stable ?: boolean ;
440+ timeout ?: number ;
441+ } = { } ) {
442+ const messagesList = this . page . getByTestId ( "messages-list" ) ;
443+ if ( ! stable ) {
444+ await expect ( messagesList ) . toMatchAriaSnapshot ( { timeout } ) ;
445+ return ;
446+ }
447+
448+ const rawSnapshot = await messagesList . ariaSnapshot ( { timeout } ) ;
449+ let normalizedSnapshot = normalizeMessagesAriaSnapshot ( rawSnapshot ) ;
344450 if ( replaceDumpPath ) {
345- await this . page . evaluate ( ( ) => {
346- const messagesList = document . querySelector (
347- "[data-testid=messages-list]" ,
348- ) ;
349- if ( ! messagesList ) {
350- throw new Error ( "Messages list not found" ) ;
351- }
352- // Scrub compaction backup paths embedded in message text
353- // e.g. .dyad/chats/1/compaction-2026-02-05T21-25-24-285Z.md
354- messagesList . innerHTML = messagesList . innerHTML . replace (
451+ // Scrub machine-specific paths after snapshotting so React-owned DOM is not mutated.
452+ normalizedSnapshot = normalizedSnapshot
453+ . replace (
355454 / \. d y a d \/ c h a t s \/ \d + \/ c o m p a c t i o n - [ ^ \s < " ] + \. m d / g,
356455 "[[compaction-backup-path]]" ,
357- ) ;
358-
359- messagesList . innerHTML = messagesList . innerHTML . replace (
360- / \[ \[ d y a d - d u m p - p a t h = ( [ ^ \] ] + ) \] \] / g,
361- "[[dyad-dump-path=*]]" ,
362- ) ;
363- } ) ;
456+ )
457+ . replace ( / \[ \[ d y a d - d u m p - p a t h = ( [ ^ \] ] + ) \] \] / g, "[[dyad-dump-path=*]]" ) ;
364458 }
365- await expect ( this . page . getByTestId ( "messages-list" ) ) . toMatchAriaSnapshot ( {
366- timeout,
367- } ) ;
459+ normalizedSnapshot = `${ normalizedSnapshot . trimEnd ( ) } \n` ;
460+ await this . expectStableMessageAriaSnapshot ( normalizedSnapshot , name ) ;
461+ }
462+
463+ async snapshotStableAria (
464+ locator : Locator ,
465+ name : string ,
466+ { timeout } : { timeout ?: number } = { } ,
467+ ) {
468+ const rawSnapshot = await locator . ariaSnapshot ( { timeout } ) ;
469+ const normalizedSnapshot = `${ normalizeMessagesAriaSnapshot ( rawSnapshot ) . trimEnd ( ) } \n` ;
470+ await this . expectStableMessageAriaSnapshot ( normalizedSnapshot , name ) ;
368471 }
369472
370473 async snapshotServerDump (
0 commit comments