Skip to content

Commit 096db6d

Browse files
committed
feat: add e2e test harness and accessibility polish
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> fix: align git status parser with ref order Change-Id: I7afe82dc1d8eb76f0c78bd6c60571a7460dfcf55 Signed-off-by: Thomas Kosiewski <tk@coder.com> fix: emit abort event and cancel existing mock streams - Emit stream-abort event when stopping mock streams to mirror real streaming behavior - Cancel any existing stream before starting a new one to prevent timer corruption - Addresses Codex review comments about mock stream lifecycle management Change-Id: I0a95493aa15dc0eda71a5c31b09b3844eeaf8187 Signed-off-by: Thomas Kosiewski <tk@coder.com> fix: persist mock stream messages to history on completion - Call HistoryService.updateHistory when stream-end fires in mock mode - Mirrors real StreamManager behavior to persist completed messages - Prevents empty assistant entries in chat.jsonl after mock runs - Addresses Codex review comment about mock persistence Change-Id: I4f4cd7fe27355a561cd4e2193a4045424918dd1e Signed-off-by: Thomas Kosiewski <tk@coder.com>
1 parent 71f10a7 commit 096db6d

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",
@@ -57,6 +58,7 @@
5758
},
5859
"devDependencies": {
5960
"@eslint/js": "^9.36.0",
61+
"@playwright/test": "^1.56.0",
6062
"@testing-library/react": "^16.3.0",
6163
"@types/bun": "^1.2.23",
6264
"@types/diff": "^8.0.0",
@@ -79,6 +81,7 @@
7981
"eslint-plugin-react": "^7.37.5",
8082
"eslint-plugin-react-hooks": "^5.2.0",
8183
"jest": "^30.1.3",
84+
"playwright": "^1.56.0",
8285
"prettier": "^3.6.2",
8386
"ts-jest": "^29.4.4",
8487
"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
@@ -138,7 +138,7 @@ const EditBarrier = styled.div`
138138
text-align: center;
139139
`;
140140

141-
const JumpToBottomIndicator = styled.div`
141+
const JumpToBottomIndicator = styled.button`
142142
position: absolute;
143143
bottom: 8px;
144144
left: 50%;
@@ -355,6 +355,11 @@ const AIViewInner: React.FC<AIViewProps> = ({
355355
onWheel={markUserInteraction}
356356
onTouchMove={markUserInteraction}
357357
onScroll={handleScroll}
358+
role="log"
359+
aria-live={canInterrupt ? "polite" : "off"}
360+
aria-busy={canInterrupt}
361+
aria-label="Conversation transcript"
362+
tabIndex={0}
358363
>
359364
{messages.length === 0 ? (
360365
<EmptyState>
@@ -398,7 +403,7 @@ const AIViewInner: React.FC<AIViewProps> = ({
398403
)}
399404
</OutputContent>
400405
{!autoScroll && (
401-
<JumpToBottomIndicator onClick={jumpToBottom}>
406+
<JumpToBottomIndicator onClick={jumpToBottom} type="button">
402407
Press {formatKeybind(KEYBINDS.JUMP_TO_BOTTOM)} to jump to bottom
403408
</JumpToBottomIndicator>
404409
)}

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";
@@ -308,6 +308,7 @@ export const ChatInput: React.FC<ChatInputProps> = ({
308308
const [mode, setMode] = useMode();
309309
const [use1M] = use1MContext();
310310
const { recentModels } = useModelLRU();
311+
const commandListId = useId();
311312

312313
const focusMessageInput = useCallback(() => {
313314
const element = inputRef.current;
@@ -743,6 +744,8 @@ export const ChatInput: React.FC<ChatInputProps> = ({
743744
onSelectSuggestion={handleCommandSelect}
744745
onDismiss={() => setShowCommandSuggestions(false)}
745746
isVisible={showCommandSuggestions}
747+
ariaLabel="Slash command suggestions"
748+
listId={commandListId}
746749
/>
747750
<InputControls data-component="ChatInputControls">
748751
<VimTextArea
@@ -755,6 +758,12 @@ export const ChatInput: React.FC<ChatInputProps> = ({
755758
suppressKeys={showCommandSuggestions ? COMMAND_SUGGESTION_KEYS : undefined}
756759
placeholder={placeholder}
757760
disabled={disabled || isSending || isCompacting}
761+
aria-label={editingMessage ? "Edit your last message" : "Message Claude"}
762+
aria-autocomplete="list"
763+
aria-controls={
764+
showCommandSuggestions && commandSuggestions.length > 0 ? commandListId : undefined
765+
}
766+
aria-expanded={showCommandSuggestions && commandSuggestions.length > 0}
758767
/>
759768
</InputControls>
760769
<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)