Skip to content

Commit f67ed87

Browse files
keppo-bot[bot]wwwillchenclaude
authored
Stabilize message ARIA snapshots (#3606)
## Summary - Add stable message ARIA snapshot normalization for chat message snapshots. - Elide noisy button descendants and normalize volatile metadata such as model names, relative timestamps, generated prompts, versions, and durations. - Add stable baselines and unit coverage for the serializer. ## Test plan - npm run fmt && npm run lint:fix && npm run ts - npm test - PLAYWRIGHT_HTML_OPEN=never npm run e2e -- e2e-tests/local_agent_advanced.spec.ts 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Will Chen <7344640+wwwillchen@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 95e4477 commit f67ed87

96 files changed

Lines changed: 1267 additions & 2267 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

e2e-tests/approve.spec.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,5 +14,7 @@ testSkipIfWindows("write to index, approve, check preview", async ({ po }) => {
1414
await expect(po.previewPanel.getPreviewIframeElement()).toBeVisible({
1515
timeout: Timeout.LONG,
1616
});
17-
await po.previewPanel.snapshotPreview();
17+
await po.previewPanel.snapshotPreview({
18+
name: "write-to-index-approve-check-preview-3.aria.yml",
19+
});
1820
});

e2e-tests/auto_approve.spec.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,5 +10,5 @@ testSkipIfWindows("auto-approve", async ({ po }) => {
1010
await expect(po.previewPanel.getPreviewIframeElement()).toBeVisible({
1111
timeout: Timeout.LONG,
1212
});
13-
await po.previewPanel.snapshotPreview();
13+
await po.previewPanel.snapshotPreview({ name: "auto-approve-2.aria.yml" });
1414
});

e2e-tests/fix_error.spec.ts

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,23 +5,29 @@ testSkipIfWindows("fix error with AI", async ({ po }) => {
55
await po.setUp({ autoApprove: true });
66
await po.sendPrompt("tc=create-error");
77

8-
await po.previewPanel.snapshotPreviewErrorBanner();
8+
await po.previewPanel.snapshotPreviewErrorBanner({
9+
name: "fix-error-with-AI-1.aria.yml",
10+
});
911

1012
await expect(
1113
po.page.getByText("Error Line 6 error", { exact: true }),
1214
).toBeVisible({ timeout: Timeout.MEDIUM });
1315
await po.page.getByText("Error Line 6 error", { exact: true }).click();
14-
await po.previewPanel.snapshotPreviewErrorBanner();
16+
await po.previewPanel.snapshotPreviewErrorBanner({
17+
name: "fix-error-with-AI-2.aria.yml",
18+
});
1519

1620
await po.previewPanel.clickFixErrorWithAI();
1721
await po.chatActions.waitForChatCompletion();
18-
await po.snapshotMessages();
22+
await po.snapshotMessages({ name: "fix-error-with-AI-3" });
1923

2024
// TODO: this is an actual bug where the error banner should not
2125
// be shown, however there's some kind of race condition and
2226
// we don't reliably detect when the HMR update has completed.
2327
// await po.previewPanel.locatePreviewErrorBanner().waitFor({ state: "hidden" });
24-
await po.previewPanel.snapshotPreview();
28+
await po.previewPanel.snapshotPreview({
29+
name: "fix-error-with-AI-4.aria.yml",
30+
});
2531
});
2632

2733
testSkipIfWindows("copy error message from banner", async ({ po }) => {

e2e-tests/helpers/fixtures.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -114,12 +114,13 @@ export const test = base.extend<{
114114
{ auto: true },
115115
],
116116
po: [
117-
async ({ electronApp, electronConfig }, use) => {
117+
async ({ electronApp, electronConfig }, use, testInfo) => {
118118
const page = await electronApp.firstWindow();
119119

120120
const po = new PageObject(electronApp, page, {
121121
userDataDir: (electronApp as any).$dyadUserDataDir,
122122
fakeLlmPort: (electronApp as any).$fakeLlmPort,
123+
testInfo,
123124
});
124125
if (electronConfig.showPnpmMinimumReleaseAgeWarning) {
125126
await page.evaluate(async () => {

e2e-tests/helpers/page-objects/PageObject.ts

Lines changed: 127 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,10 @@
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";
88
import { ElectronApplication } from "playwright";
99
import fs from "fs";
10+
import path from "path";
1011

1112
import { generateAppFilesSnapshotData } from "../generateAppFilesSnapshotData";
1213
import {
@@ -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
/\.dyad\/chats\/\d+\/compaction-[^\s<"]+\.md/g,
356455
"[[compaction-backup-path]]",
357-
);
358-
359-
messagesList.innerHTML = messagesList.innerHTML.replace(
360-
/\[\[dyad-dump-path=([^\]]+)\]\]/g,
361-
"[[dyad-dump-path=*]]",
362-
);
363-
});
456+
)
457+
.replace(/\[\[dyad-dump-path=([^\]]+)\]\]/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(

e2e-tests/helpers/page-objects/components/PreviewPanel.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -269,8 +269,9 @@ export class PreviewPanel {
269269
await this.page.getByRole("button", { name: /Fix All Errors/ }).click();
270270
}
271271

272-
async snapshotPreviewErrorBanner() {
272+
async snapshotPreviewErrorBanner({ name }: { name?: string } = {}) {
273273
await expect(this.locatePreviewErrorBanner()).toMatchAriaSnapshot({
274+
name,
274275
timeout: Timeout.LONG,
275276
});
276277
}

e2e-tests/helpers/utils/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,3 +10,4 @@ export {
1010
} from "./normalization";
1111

1212
export { prettifyDump, type PrettifyDumpOptions } from "./dump-prettifier";
13+
export { normalizeMessagesAriaSnapshot } from "./stable-aria-snapshot";

0 commit comments

Comments
 (0)