Skip to content

Commit 57a891a

Browse files
authored
feat: add e2e test harness and accessibility polish (#88)
Introduce Playwright-driven Electron e2e runner with mock AI scenario to capture deterministic transcripts and videos for review flows. Seed test config via CMUX_TEST_ROOT, adjust Config to honor it, and teach main process to boot against the dev server during e2e runs. Stub mermaid in Vite to keep captures predictable and expand tsconfig coverage for new test sources. Harden Bash and StreamManager tests for CI timing variance. Improve keyboard and screen-reader access across chat transcript, command palette, slider, sidebar, and toast UI. Signed-off-by: Thomas Kosiewski <tk@coder.com>
1 parent 1978362 commit 57a891a

Some content is hidden

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

42 files changed

+2471
-416
lines changed

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,3 +83,5 @@ TESTING.md
8383
FEATURE_SUMMARY.md
8484
CODE_CHANGES.md
8585
README_COMPACT_HERE.md
86+
artifacts/
87+
tests/e2e/tmp/

Makefile

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@
2121
.PHONY: all build dev start clean help
2222
.PHONY: build-main build-preload build-renderer
2323
.PHONY: lint lint-fix fmt fmt-check fmt-shell fmt-shell-check typecheck static-check
24-
.PHONY: test test-unit test-integration test-watch test-coverage
24+
.PHONY: test test-unit test-integration test-watch test-coverage test-e2e
2525
.PHONY: dist dist-mac dist-win dist-linux
2626
.PHONY: docs docs-build docs-watch
2727

@@ -110,6 +110,9 @@ test-watch: ## Run tests in watch mode
110110
test-coverage: ## Run tests with coverage
111111
@./scripts/test.sh --coverage
112112

113+
test-e2e: ## Run end-to-end tests
114+
@PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1 bun x playwright test --project=electron
115+
113116
## Distribution
114117
dist: build ## Build distributable packages
115118
@bun x electron-builder --publish never

bun.lock

Lines changed: 112 additions & 162 deletions
Large diffs are not rendered by default.

bunfig.toml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
[test]
2+
root = "src"
3+
match = [
4+
"**/*.test.ts",
5+
"**/*.test.tsx",
6+
"**/*.spec.ts",
7+
"**/*.spec.tsx"
8+
]

package.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
"test:watch": "make test-watch",
2020
"test:coverage": "make test-coverage",
2121
"test:integration": "make test-integration",
22+
"test:e2e": "make test-e2e",
2223
"dist": "make dist",
2324
"dist:mac": "make dist-mac",
2425
"dist:win": "make dist-win",
@@ -59,6 +60,7 @@
5960
},
6061
"devDependencies": {
6162
"@eslint/js": "^9.36.0",
63+
"@playwright/test": "^1.56.0",
6264
"@testing-library/react": "^16.3.0",
6365
"@types/bun": "^1.2.23",
6466
"@types/diff": "^8.0.0",
@@ -81,6 +83,7 @@
8183
"eslint-plugin-react": "^7.37.5",
8284
"eslint-plugin-react-hooks": "^5.2.0",
8385
"jest": "^30.1.3",
86+
"playwright": "^1.56.0",
8487
"prettier": "^3.6.2",
8588
"ts-jest": "^29.4.4",
8689
"tsc-alias": "^1.8.16",

playwright.config.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import { defineConfig } from "@playwright/test";
2+
3+
const isCI = process.env.CI === "true";
4+
5+
export default defineConfig({
6+
testDir: "./tests/e2e",
7+
timeout: 120_000,
8+
expect: {
9+
timeout: 5_000,
10+
},
11+
fullyParallel: false,
12+
forbidOnly: isCI,
13+
retries: isCI ? 1 : 0,
14+
reporter: [
15+
["list"],
16+
["html", { outputFolder: "artifacts/playwright-report", open: "never" }],
17+
],
18+
workers: 1,
19+
use: {
20+
trace: isCI ? "on-first-retry" : "retain-on-failure",
21+
screenshot: "only-on-failure",
22+
video: {
23+
mode: "on",
24+
size: { width: 1280, height: 720 },
25+
},
26+
},
27+
outputDir: "artifacts/playwright-output",
28+
projects: [
29+
{
30+
name: "electron",
31+
testDir: "./tests/e2e",
32+
},
33+
],
34+
});

src/components/AIView.tsx

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -142,7 +142,7 @@ const EditBarrier = styled.div`
142142
text-align: center;
143143
`;
144144

145-
const JumpToBottomIndicator = styled.div`
145+
const JumpToBottomIndicator = styled.button`
146146
position: absolute;
147147
bottom: 8px;
148148
left: 50%;
@@ -391,6 +391,11 @@ const AIViewInner: React.FC<AIViewProps> = ({
391391
onWheel={markUserInteraction}
392392
onTouchMove={markUserInteraction}
393393
onScroll={handleScroll}
394+
role="log"
395+
aria-live={canInterrupt ? "polite" : "off"}
396+
aria-busy={canInterrupt}
397+
aria-label="Conversation transcript"
398+
tabIndex={0}
394399
>
395400
{messages.length === 0 ? (
396401
<EmptyState>
@@ -444,7 +449,7 @@ const AIViewInner: React.FC<AIViewProps> = ({
444449
)}
445450
</OutputContent>
446451
{!autoScroll && (
447-
<JumpToBottomIndicator onClick={jumpToBottom}>
452+
<JumpToBottomIndicator onClick={jumpToBottom} type="button">
448453
Press {formatKeybind(KEYBINDS.JUMP_TO_BOTTOM)} to jump to bottom
449454
</JumpToBottomIndicator>
450455
)}

src/components/ChatInput.tsx

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import React, { useState, useRef, useCallback, useEffect } from "react";
1+
import React, { useState, useRef, useCallback, useEffect, useId } from "react";
22
import styled from "@emotion/styled";
33
import { CommandSuggestions, COMMAND_SUGGESTION_KEYS } from "./CommandSuggestions";
44
import type { Toast } from "./ChatInputToast";
@@ -300,6 +300,7 @@ export const ChatInput: React.FC<ChatInputProps> = ({
300300
const modelSelectorRef = useRef<ModelSelectorRef>(null);
301301
const [mode, setMode] = useMode();
302302
const { recentModels } = useModelLRU();
303+
const commandListId = useId();
303304

304305
// Get current send message options from shared hook (must be at component top level)
305306
const sendMessageOptions = useSendMessageOptions(workspaceId);
@@ -731,6 +732,8 @@ export const ChatInput: React.FC<ChatInputProps> = ({
731732
onSelectSuggestion={handleCommandSelect}
732733
onDismiss={() => setShowCommandSuggestions(false)}
733734
isVisible={showCommandSuggestions}
735+
ariaLabel="Slash command suggestions"
736+
listId={commandListId}
734737
/>
735738
<InputControls data-component="ChatInputControls">
736739
<VimTextArea
@@ -743,6 +746,12 @@ export const ChatInput: React.FC<ChatInputProps> = ({
743746
suppressKeys={showCommandSuggestions ? COMMAND_SUGGESTION_KEYS : undefined}
744747
placeholder={placeholder}
745748
disabled={disabled || isSending || isCompacting}
749+
aria-label={editingMessage ? "Edit your last message" : "Message Claude"}
750+
aria-autocomplete="list"
751+
aria-controls={
752+
showCommandSuggestions && commandSuggestions.length > 0 ? commandListId : undefined
753+
}
754+
aria-expanded={showCommandSuggestions && commandSuggestions.length > 0}
746755
/>
747756
</InputControls>
748757
<ModeToggles data-component="ChatModeToggles">

src/components/ChatInputToast.tsx

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -202,7 +202,7 @@ export const ChatInputToast: React.FC<ChatInputToastProps> = ({ toast, onDismiss
202202
if (isRichError) {
203203
return (
204204
<ToastWrapper>
205-
<ErrorContainer>
205+
<ErrorContainer role="alert" aria-live="assertive">
206206
<div style={{ display: "flex", alignItems: "flex-start", gap: "6px" }}>
207207
<ToastIcon></ToastIcon>
208208
<div style={{ flex: 1 }}>
@@ -212,7 +212,9 @@ export const ChatInputToast: React.FC<ChatInputToastProps> = ({ toast, onDismiss
212212
<ErrorDetails>{toast.message}</ErrorDetails>
213213
{toast.solution && <ErrorSolution>{toast.solution}</ErrorSolution>}
214214
</div>
215-
<CloseButton onClick={handleDismiss}>×</CloseButton>
215+
<CloseButton onClick={handleDismiss} aria-label="Dismiss">
216+
×
217+
</CloseButton>
216218
</div>
217219
</ErrorContainer>
218220
</ToastWrapper>
@@ -222,7 +224,12 @@ export const ChatInputToast: React.FC<ChatInputToastProps> = ({ toast, onDismiss
222224
// Regular toast for simple messages and success
223225
return (
224226
<ToastWrapper>
225-
<ToastContainer type={toast.type} isLeaving={isLeaving}>
227+
<ToastContainer
228+
type={toast.type}
229+
isLeaving={isLeaving}
230+
role={toast.type === "error" ? "alert" : "status"}
231+
aria-live={toast.type === "error" ? "assertive" : "polite"}
232+
>
226233
<ToastIcon>{toast.type === "success" ? "✓" : "⚠"}</ToastIcon>
227234
<ToastContent>
228235
{toast.title && <ToastTitle>{toast.title}</ToastTitle>}

src/components/ChatMetaSidebar.tsx

Lines changed: 36 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -64,19 +64,49 @@ export const ChatMetaSidebar: React.FC<ChatMetaSidebarProps> = ({ workspaceId })
6464
"costs"
6565
);
6666

67+
const baseId = `chat-meta-${workspaceId}`;
68+
const costsTabId = `${baseId}-tab-costs`;
69+
const toolsTabId = `${baseId}-tab-tools`;
70+
const costsPanelId = `${baseId}-panel-costs`;
71+
const toolsPanelId = `${baseId}-panel-tools`;
72+
6773
return (
68-
<SidebarContainer>
69-
<TabBar>
70-
<TabButton active={selectedTab === "costs"} onClick={() => setSelectedTab("costs")}>
74+
<SidebarContainer role="complementary" aria-label="Workspace insights">
75+
<TabBar role="tablist" aria-label="Metadata views">
76+
<TabButton
77+
active={selectedTab === "costs"}
78+
onClick={() => setSelectedTab("costs")}
79+
id={costsTabId}
80+
role="tab"
81+
type="button"
82+
aria-selected={selectedTab === "costs"}
83+
aria-controls={costsPanelId}
84+
>
7185
Costs
7286
</TabButton>
73-
<TabButton active={selectedTab === "tools"} onClick={() => setSelectedTab("tools")}>
87+
<TabButton
88+
active={selectedTab === "tools"}
89+
onClick={() => setSelectedTab("tools")}
90+
id={toolsTabId}
91+
role="tab"
92+
type="button"
93+
aria-selected={selectedTab === "tools"}
94+
aria-controls={toolsPanelId}
95+
>
7496
Tools
7597
</TabButton>
7698
</TabBar>
7799
<TabContent>
78-
{selectedTab === "costs" && <CostsTab />}
79-
{selectedTab === "tools" && <ToolsTab />}
100+
{selectedTab === "costs" && (
101+
<div role="tabpanel" id={costsPanelId} aria-labelledby={costsTabId}>
102+
<CostsTab />
103+
</div>
104+
)}
105+
{selectedTab === "tools" && (
106+
<div role="tabpanel" id={toolsPanelId} aria-labelledby={toolsTabId}>
107+
<ToolsTab />
108+
</div>
109+
)}
80110
</TabContent>
81111
</SidebarContainer>
82112
);

0 commit comments

Comments
 (0)